Accessible, Global, Printable

Tailwind CSS Intermediate/Advanced

Chapter 9 · Accessibility, Internationalisation and Print

Tailwind gives you variants for the user's preferences and situation: focus-visible:, motion-reduce:, contrast-more:, forced-colors:, rtl:, print:. The variants are easy to write and easy to get wrong without noticing, because a page that ignores these needs still looks fine to its author. So this chapter does what the earlier ones did: it turns each preference on in a real browser and measures what happens. The course Web Accessibility (a11y) covers the standards and the reasoning in depth; this chapter is about the Tailwind side.

Every example here was run, except where marked
Tailwind 4.3.3, Node 24.18.0, Windows 11, headless Chrome 154 driven over the DevTools Protocol (real key presses and mouse clicks; Emulation.setEmulatedMedia for the preferences; Accessibility.getFullAXTree for what assistive technology is offered; Page.printToPDF for print). Not run: a real screen reader, a real Windows contrast theme (Chrome's emulated forced colours only), a real printer, an automated audit tool such as axe, and any browser except Chrome. Passing these checks does not make a page accessible; they show what each class does.

Focus Styles

Keyboard users need to see where they are. Three habits matter, and I tested each on four controls: a button using focus:, a button using focus-visible:, a button with outline-hidden and no replacement, and a text input using focus-visible:. I clicked each with the mouse, then pressed Tab from a fresh page load.

<button class="focus:outline-2 focus:outline-offset-2 focus:outline-blue-600">focus:</button> <button class="focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600">focus-visible:</button> <button class="outline-hidden">outline-hidden, no replacement</button> <input class="border px-2 py-1 focus-visible:outline-2 focus-visible:outline-blue-600">
MOUSE click :focus-visible outline shown focus: false yes (solid 2px) the ring appears after every mouse click focus-visible: false no the ring stays away for mouse users on a button outline-hidden false no input true yes (solid 2px) a text field always counts as keyboard-relevant TAB key :focus-visible outline shown focus: true solid 2px focus-visible: true solid 2px outline-hidden true none <- a keyboard user cannot see where they are input true solid 2px

The pattern to adopt: use focus-visible: for the ring (mouse users on buttons are not shown it, keyboard users always are), and never use outline-hidden or outline-none without putting a visible replacement on focus-visible:. The third button is the classic bug: it looks tidier and has no focus indicator at all. Chapter 8 explained that outline-hidden keeps a transparent outline in forced-colours mode; it still shows nothing in normal mode.

Text for Screen Readers Only

A star icon on a link has no text. You can add text that is invisible on screen but still read aloud. But hidden and invisible remove text from assistive technology as well. I asked Chrome for the accessibility tree and read the name it computed for each of five links:

<a href="#"><span class="sr-only">Profile (sr-only)</span><i>★</i></a> <a href="#"><span class="hidden">Profile (hidden)</span><i>★</i></a> <a href="#"><span class="invisible">Profile (invisible)</span><i>★</i></a> <a href="#" aria-label="aria-label only"><i>★</i></a>
accessible name of each link, from the accessibility tree: sr-only "Profile (sr-only) ★" hidden "★" the text is gone invisible "★" the text is gone aria-label "aria-label only" sr-only computed: position absolute, width 1px, height 1px, overflow hidden, white-space nowrap

Only sr-only keeps the words in the name. The same trick builds a skip link, a link that is invisible until a keyboard user reaches it:

<a href="#main" class="sr-only focus:not-sr-only focus:absolute focus:left-2 focus:top-2 focus:bg-white focus:p-2">Skip to content</a>
before focus: position absolute, width 1px, background transparent after Tab to it: position absolute, width 123.391px, background rgb(255,255,255), left 8px, top 8px

focus:not-sr-only undoes the hiding exactly when the link receives focus, which is why the skip link is both hidden for mouse users and reachable by keyboard.

Contrast

The WCAG minimum for normal text is 4.5 to 1 (3 to 1 for large text and interface parts). Tailwind's palette is not guaranteed to meet it, and the common mistakes are light greys and white text on mid-tone colours. I measured the ratio for common pairs by reading each element's computed colours, converting them to sRGB through a canvas, and applying the WCAG formula:

TextBackgroundRatioResult
text-slate-400 (#90a1b9)white2.63fails
text-gray-400 (#99a1af)white2.60fails
text-slate-500 (#62748e)white4.76passes
text-gray-500 (#6a7282)white4.84passes
text-slate-500bg-slate-100 (#f1f5f9)4.35fails for normal text
text-slate-600 (#45556c)white7.58passes
whitebg-blue-500 (#2b7fff)3.76large text only
whitebg-blue-600 (#155dfc)5.25passes
whitebg-red-500 (#fb2c36)3.81large text only
whitebg-emerald-500 (#00bc7d)2.47fails
whitebg-emerald-700 (#007a55)5.36passes
text-slate-900bg-yellow-300 (#ffdf20)13.44passes

Two surprises. A grey that passes on white (slate-500, 4.76) fails on a light grey panel (4.35). And white on emerald-500 is only 2.47, worse than most people guess; one step darker passes. Users can also ask for more or less contrast, and Tailwind has variants for that. With contrast-more: and contrast-less: set on a paragraph and a border, I switched Chrome's emulated prefers-contrast:

<p class="text-slate-500 contrast-more:text-slate-900">...</p> <div class="border border-slate-300 contrast-more:border-slate-900 contrast-less:border-slate-100">
prefers-contrast: no-preference text slate-500 (oklch 0.554) border slate-300 (0.869) prefers-contrast: more text slate-900 (oklch 0.208) border slate-900 (0.208) prefers-contrast: less text slate-500 (unchanged) border slate-100 (0.968)

Reduced Motion

Some people feel ill from animation. The setting is prefers-reduced-motion; Tailwind gives two variants, motion-reduce: (the user asked for less) and motion-safe: (they did not). I used four elements and switched the emulated preference:

<div class="animate-spin"> <!-- m1: ignores the preference --> <div class="animate-spin motion-reduce:animate-none"> <!-- m2 --> <div class="motion-safe:animate-spin"> <!-- m3 --> <div class="transition duration-700 hover:translate-x-4 motion-reduce:transition-none"> <!-- m4 -->
no preference prefers-reduced-motion: reduce m1 animate-spin spin spin <- still spinning m2 motion-reduce:animate-none spin none m3 motion-safe:animate-spin spin none m4 transition-property (all listed) none (duration 0.7s is unchanged; the property is none)

The lesson is in the first row: nothing is automatic. An animation without one of the two variants runs for everybody. The safer default is motion-safe: on the animation itself, so that it exists only when the user has not asked for less.

Forced Colours

In Windows High Contrast and similar modes the browser replaces your colours with the user's chosen system palette. The variant is forced-colors:, with not-forced-colors: for the opposite and forced-color-adjust-none to opt an element out. I switched Chrome's emulated forced-colors: active:

x1 <button class="bg-blue-600 text-white forced-colors:bg-[ButtonFace] forced-colors:text-[ButtonText]"> x2 <button class="bg-blue-600 text-white"> (no override) x3 <div class="bg-blue-600 text-white forced-color-adjust-none"> x4 <div class="not-forced-colors:bg-blue-600">
normal forced-colors: active x1 bg blue-600, text white -> background white, text black (system colours) x2 bg blue-600, text white -> background white, text black (replaced anyway) x3 bg blue-600, text white -> unchanged: forced-color-adjust: none keeps your colours x4 bg blue-600 -> transparent: the not-forced-colors: rule no longer applies matchMedia('(forced-colors: active)').matches -> true

The browser already handled x2, so you rarely need to write forced-colors: yourself. The value of forced-color-adjust-none is also its danger: x3 kept your colours, so you now own the contrast. The cases that need a variant are decorative separators and custom controls built from background colours alone: in forced colours a background-only bar can vanish, so give it a border or an outline. I tested with Chrome's emulation, not a real Windows contrast theme.

Logical Properties and Right-to-Left

ml-4 always means "left". For a right-to-left language (Arabic, Hebrew) you want "start". Tailwind's logical utilities (ms-, me-, ps-, pe-, border-s, start-, end-, text-start) follow the writing direction. I laid out one block with logical classes and one with physical ones, then set dir="rtl" on their container:

<div class="ms-4 me-8 ps-2 pe-6 border-s-4 text-start">logical</div> <div class="ml-4 mr-8 pl-2 pr-6 border-l-4 text-left">physical</div> <div class="rtl:bg-red-500 ltr:bg-green-500 rtl:ms-10">rtl/ltr</div> <div class="relative h-8"><span class="absolute start-0">start-0</span><span class="absolute end-0">end-0</span></div>
LTR RTL (dir="rtl" on the container) logical margin-left 16 margin-right 32 border-left 4 -> margin-left 32 margin-right 16 border-RIGHT 4, text-align start physical margin-left 16 margin-right 32 border-left 4 -> unchanged: still left (wrong for RTL) rtl/ltr green, margin-left 0 -> red, margin-right 40 (rtl:ms-10) start-0 span at x = 16, end-0 span at x = 826 -> start-0 at x = 822, end-0 at x = 16

The logical block mirrored by itself; the physical one stayed put. rtl: and ltr: are for the cases logical utilities cannot express (an icon that must flip, or a different colour). Tailwind 4.3.3 also has logical block-axis utilities. I built each and checked the generated declaration:

ClassGeneratesExists in 4.3.3
pbs-4, pbe-1padding-block-start, padding-block-endyes
mbs-3, mbe-2margin-block-start, margin-block-endyes
inline-4, block-8inline-size, block-sizeyes
min-inline-2, max-block-10min-inline-size, max-block-sizeyes
border-bs-2border-block-start-widthyes
inset-s-1, inset-bs-0, start-0inset-inline-start, inset-block-startyes
pis-2, pie-3, mis-1, mie-4nothingno: use ps-, pe-, ms-, me-

Note the last row: the CSS property names (padding-inline-start) suggest pis-, but Tailwind's inline-axis names are the shorter ps- and pe-, and the block-axis ones are pbs- and pbe-. I did not check which Tailwind release added the block-axis utilities; they exist in 4.3.3.

Print Styles

The print: variant applies under @media print. I emulated print media for the computed styles, and used Chrome's PDF output (A4, 0.4 inch margins) to see real page breaks:

<nav class="bg-slate-200 print:hidden">...</nav> <div class="hidden print:block">only in print</div> <div class="bg-yellow-200 print:bg-white print:text-black">print colours</div>
screen print media nav (print:hidden) display block display none "only in print" display none display block bg-yellow-200 print:bg-white yellow rgb(255,255,255), text rgb(0,0,0)

The PDF test used a navigation bar (160 px tall) and three sections of 320 px:

a.html nav, three sections 2 PDF pages b.html same, nav has print:hidden 1 PDF page c.html b.html + break-before-page on sections two and three 3 PDF pages

Hiding the navigation brought the document from two pages to one; break-before-page forces a section onto its own page. break-inside-avoid is the companion that keeps a card from splitting across two pages. One thing I assumed and got wrong: Tailwind 4.3.3 has no print-color-adjust utility (print-color-adjust-exact produced no rule). The arbitrary property [print-color-adjust:exact] does work, and does generate print-color-adjust: exact. Whether backgrounds print also depends on the user's print dialog; my PDF test asked for them explicitly (printBackground). The site's own PDF builder works by loading chapters in headless Chrome, so print: rules in a chapter should apply to its PDF (I did not test that builder).

An Accessibility Review Checklist for Tailwind

  • Every interactive element has a visible keyboard focus style on focus-visible:; no bare outline-hidden or outline-none.
  • Icon-only controls have a name: sr-only text or aria-label; never hidden or invisible text.
  • A skip link exists (sr-only focus:not-sr-only).
  • Text and interface colours pass the WCAG ratios (measure; do not guess: emerald-500 with white text fails); contrast-more: variants for subtle greys.
  • Every animation is under motion-safe: or has motion-reduce: alternatives.
  • No meaning conveyed by background colour alone (forced colours removes it); forced-color-adjust-none only where you have checked contrast.
  • Layout uses logical utilities (ms-, ps-, start-, text-start); rtl: only for things that must flip.
  • Print: hide navigation, show URLs or notes if needed, set break behaviour, and test the PDF.
Mistakes while preparing this chapter
1. My first contrast table said 21 to 1 for most pairs. Those classes were not in the built CSS (the scanner had only seen the page), so the text was black on white. How to avoid it: a result of exactly 21.00 (or 1.00) for a colour pair is a red flag; build the classes and add a check you can predict.

2. I counted four Tab presses to reach the skip link and landed on the wrong control. The page has four earlier focusable elements. How to avoid it: print the focused element's id after each key press instead of counting.

3. I assumed a print-color-adjust-exact utility. It produced no rule. How to avoid it: build the class and look for a rule before writing it into a lesson.

4. My first print test fitted on one page whether or not the navigation was hidden, so it proved nothing. I made the sections taller. How to avoid it: work out what result would show a difference before running the test.

Hands-On Exercises

Exercise 1

Make four controls (focus:, focus-visible:, outline-hidden alone, an input). Click each with the mouse and Tab through them, and report :focus-visible and the outline for each case. Then build five links (sr-only, hidden, invisible, aria-label, a skip link) and read their names from the accessibility tree.

📄 View solution
Exercise 2

Measure the WCAG contrast ratio of twelve Tailwind colour pairs by reading computed colours. Include a result you can predict as a check. Then show what contrast-more:, motion-reduce:, motion-safe: and forced-colors: change by switching the emulated media features.

📄 View solution
Exercise 3

Build a block with logical classes and one with physical classes, switch the container to dir="rtl" and report what moved. Then make a page that prints to one PDF page with print:hidden on the navigation and to three pages with break-before-page, and report the page counts.

📄 View solution

Chapter 9 Quick Reference

  • Focus ring on focus-visible:: mouse clicks on buttons do not show it, keyboard always does. outline-hidden with no replacement left keyboard focus invisible
  • sr-only keeps text in the accessible name (the accessibility tree showed it); hidden and invisible remove it. Skip link: sr-only focus:not-sr-only focus:absolute focus:left-2 focus:top-2 focus:bg-white
  • Contrast is not guaranteed by the palette: slate-400/white 2.63, white on emerald-500 2.47 fail; slate-500/white 4.76 and white on blue-600 5.25 pass. contrast-more:/contrast-less: respond to prefers-contrast
  • Animations run for everyone unless you add motion-safe: or motion-reduce:; motion-reduce:transition-none sets the property to none
  • forced-colors: / not-forced-colors: / forced-color-adjust-none: system colours replace yours; backgrounds alone can vanish; opting out makes contrast your job (tested with emulation only)
  • Logical utilities mirror in RTL by themselves (ms- me- ps- pe- border-s start- end- text-start); physical ml- does not. pbs-/pbe-/mbs-/mbe-/inline-/block- exist in 4.3.3; pis-/mis- do not
  • print:hidden took a test document from 2 PDF pages to 1; break-before-page gave 3; no print-color-adjust-* utility (use [print-color-adjust:exact])
Coming next
Chapter 10 is the capstone: a small themed, accessible component kit (button, input, card, tabs, dialog) with design tokens, two brand themes plus dark mode, cva variants and a documentation page, wired into Vite and into a second tool, with a shipping checklist and the course recap. Start with Tailwind CSS Intermediate/Advanced 10.