tailwind-a11y 0.13.1 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -57,7 +57,7 @@ Exits `1` on violations — safe to use as a CI gate.
57
57
  | Touch target | 2.5.8 (AA) | Interactive elements under 24×24px — or 44×44px with `--strict` (2.5.5, AAA) |
58
58
  | Focus indicator | 2.4.7 (AA) | `focus:outline-none` with no visible replacement |
59
59
  | Focus indicator contrast | 1.4.11 (AA) | A present `outline-*`/`ring-*` focus indicator below 3:1 contrast — or also below the 2px minimum thickness with `--strict` (2.4.13, AAA) |
60
- | Reduced motion | 2.3.3 (AAA, `--strict` only) | A `hover:`/`focus:`/`focus-visible:`/`active:`-scoped `scale-*`/`rotate-*`/`translate-*`/`skew-*` change with an unscoped `transition`/`transition-all`/`transition-transform` and no `motion-reduce:`/`motion-safe:` handling |
60
+ | Reduced motion | 2.3.3 (AAA, `--strict` only) | A `hover:`/`focus:`/`focus-visible:`/`active:`-scoped `scale-*`/`rotate-*`/`translate-*`/`skew-*` change with an unscoped `transition`/`transition-all`/`transition-transform`, or an `animate-spin`/`-ping`/`-bounce` under the same variants, with no `motion-reduce:`/`motion-safe:` handling |
61
61
 
62
62
  ## Scope
63
63
 
package/dist/cli.js CHANGED
@@ -36,7 +36,9 @@ function formatViolation(v) {
36
36
  : base;
37
37
  }
38
38
  case "reduced-motion":
39
- return `${v.line}: <${v.tagName}> animates ${v.motionClass} via ${v.transitionClass} with no motion-reduce:transition-none/transform-none guard — WCAG 2.3.3 requires motion animation triggered by interaction to be disableable`;
39
+ return v.mechanism === "animate"
40
+ ? `${v.line}: <${v.tagName}> animates ${v.motionClass} via a CSS animation with no motion-reduce:animate-none guard — WCAG 2.3.3 requires motion animation triggered by interaction to be disableable`
41
+ : `${v.line}: <${v.tagName}> animates ${v.motionClass} via ${v.transitionClass} with no motion-reduce:transition-none/transform-none guard — WCAG 2.3.3 requires motion animation triggered by interaction to be disableable`;
40
42
  }
41
43
  }
42
44
  function groupByFile(items) {
@@ -17,6 +17,9 @@ function baseUtility(raw) {
17
17
  function variantSegments(raw) {
18
18
  return raw.split(":").slice(0, -1);
19
19
  }
20
+ function isAnimateBase(base) {
21
+ return base.startsWith("animate-");
22
+ }
20
23
  export function extractReducedMotionChecks(code, filePath) {
21
24
  const ast = parseJSX(code, filePath);
22
25
  if (!ast)
@@ -31,12 +34,32 @@ export function extractReducedMotionChecks(code, filePath) {
31
34
  const classes = className.split(/\s+/).filter(Boolean);
32
35
  const hasTransitionBase = classes.some((raw) => TRANSITION_BASES.has(baseUtility(raw)));
33
36
  const hasInteractionClass = classes.some((raw) => variantSegments(raw).some((v) => INTERACTION_VARIANTS.has(v)));
37
+ // A second, independent candidacy path for animate-* utilities, which
38
+ // carry their own `animation` property and need no transition-* base
39
+ // at all -- `hover:animate-bounce` alone must be a candidate even
40
+ // though `hasTransitionBase` is false. Deliberately a SINGLE-class
41
+ // condition (one class must be both an animate-* base AND
42
+ // interaction-scoped in its own variant stack), not two independent
43
+ // whole-element flags like hasTransitionBase/hasInteractionClass
44
+ // above: unlike transition-transform (never itself interaction-scoped)
45
+ // paired with a separate hover:scale-110, an animate-* class is
46
+ // simultaneously its own trigger and its own animator. Two independent
47
+ // flags would wrongly treat `animate-spin hover:text-red-500` (an
48
+ // unscoped, continuously-running animation next to an unrelated hover
49
+ // class) as a candidate -- that's 2.2.2 (Pause/Stop/Hide) territory,
50
+ // not 2.3.3, per the same reasoning checkReducedMotion.ts already
51
+ // documents for why unscoped animate-* is out of scope here.
52
+ const hasInteractionScopedAnimate = classes.some((raw) => {
53
+ const base = baseUtility(raw);
54
+ return isAnimateBase(base) && variantSegments(raw).some((v) => INTERACTION_VARIANTS.has(v));
55
+ });
34
56
  // Not a candidate at all unless there's some transition utility
35
- // (scoped or not) *and* some interaction-scoped class -- narrows the
36
- // set of elements checkReducedMotion.ts has to reason about, without
37
- // pre-deciding any of the nuance (unscoped vs motion-safe:, identity
38
- // values, motion-reduce: guards) that belongs in the rule.
39
- if (!hasTransitionBase || !hasInteractionClass)
57
+ // (scoped or not) *and* some interaction-scoped class, OR an
58
+ // interaction-scoped animate-* class -- narrows the set of elements
59
+ // checkReducedMotion.ts has to reason about, without pre-deciding any
60
+ // of the nuance (unscoped vs motion-safe:, identity/non-motion
61
+ // animate-* names, motion-reduce: guards) that belongs in the rule.
62
+ if ((!hasTransitionBase || !hasInteractionClass) && !hasInteractionScopedAnimate)
40
63
  return;
41
64
  checks.push({
42
65
  file: filePath,
@@ -131,6 +131,24 @@ export function suggestContrastFix(textClass, bgClass, required, palette = defau
131
131
  }
132
132
  return null;
133
133
  }
134
+ // resolveColorValue() bails out on any bg-side opacity modifier before ever
135
+ // checking whether the underlying color is real (background-side opacity
136
+ // compositing is out of scope -- see CLAUDE.md -- since it depends on
137
+ // knowing what's rendered behind an already-semi-transparent background).
138
+ // Caught in independent adversarial testing: this made checkContrastValueSkips
139
+ // report `bg-gray-800/50 is not a recognized color`, even though gray-800
140
+ // is a perfectly recognized default-palette color -- a developer reading
141
+ // that message would reasonably (and pointlessly) try adding a theme entry
142
+ // for it. Distinguishes the two cases by re-resolving the color with the
143
+ // opacity suffix stripped off: if that succeeds, the real reason is the
144
+ // out-of-scope opacity, not an unrecognized color.
145
+ function bgSkipReason(bgColorClass, palette) {
146
+ const { base, alpha } = splitOpacityModifier(bgColorClass);
147
+ if (alpha < 1 && resolveColorValue(base, palette) !== null) {
148
+ return `${bgColorClass} is a recognized color, but background-side opacity isn't resolved (compositing it correctly requires knowing what's rendered behind it) — skipped`;
149
+ }
150
+ return `${bgColorClass} is not a recognized color (custom theme color or unsupported arbitrary value) — skipped`;
151
+ }
134
152
  // A candidate that extractChecks *did* find a background for, but whose
135
153
  // text or bg utility didn't resolve to a known value (custom theme color,
136
154
  // non-hex arbitrary value, background-side opacity shorthand) — surfaced
@@ -150,11 +168,7 @@ export function checkContrastValueSkips(checks, palette = defaultPalette) {
150
168
  const bgHex = resolveColorValue(check.bgColorClass, palette);
151
169
  const bgRgb = bgHex ? hexToRgb(bgHex) : null;
152
170
  if (!bgRgb) {
153
- skips.push({
154
- file: check.file,
155
- line: check.line,
156
- reason: `${check.bgColorClass} is not a recognized color (custom theme color or unsupported arbitrary value) — skipped`,
157
- });
171
+ skips.push({ file: check.file, line: check.line, reason: bgSkipReason(check.bgColorClass, palette) });
158
172
  continue;
159
173
  }
160
174
  const { alpha } = splitOpacityModifier(check.textColorClass);
@@ -11,9 +11,15 @@ const REMOVAL_BASE = "outline-none";
11
11
  // resets every element to `border: 0 solid`, so border-width stays 0
12
12
  // regardless of style and no border is ever drawn, same failure mode as
13
13
  // border-0.
14
+ // inset-shadow-none/inset-ring-0 (Tailwind v4's inset-* box-shadow family,
15
+ // missed by the original one-time audit -- these utilities didn't exist,
16
+ // or weren't considered, at the time) are the exact same failure mode as
17
+ // shadow-none/ring-0: verified against a real build that inset-shadow-none
18
+ // computes to a fully transparent shadow and inset-ring-0 computes to a
19
+ // 0px-wide inset ring, both real but invisible.
14
20
  const DEGENERATE_BASES = new Set([
15
21
  "outline-none", "ring-0", "border-0", "shadow-none", "bg-transparent",
16
- "border-none", "border-hidden",
22
+ "border-none", "border-hidden", "inset-shadow-none", "inset-ring-0",
17
23
  ]);
18
24
  // Modifier-only utilities (opacity/offset/inset) don't set a concrete value
19
25
  // on their own — e.g. bg-opacity-50 with no bg-* color, or ring-offset-4
@@ -58,16 +64,35 @@ const NON_VISUAL_PATTERN = /^bg-blend-|^border-spacing(-[xy])?-/;
58
64
  // offset cases above, but shaped like a color token rather than a fixed
59
65
  // suffix, so it reuses COLOR_TOKEN (the exact same "is this a color value"
60
66
  // test extractClasses.ts uses) instead of a third, drifting definition of
61
- // what a color looks like.
67
+ // what a color looks like. inset-shadow-{color}/inset-ring-{color} are the
68
+ // same mechanism one prefix-family over (verified against a real build:
69
+ // inset-shadow-blue-500 alone only sets --tw-inset-shadow-color,
70
+ // inset-ring-blue-500 alone only sets --tw-inset-ring-color -- neither
71
+ // produces a box-shadow without a companion size utility).
62
72
  function isColorOnlyShadowOrRing(base) {
63
- const shadowMatch = /^shadow-(.+)$/.exec(base);
73
+ const shadowMatch = /^(?:inset-)?shadow-(.+)$/.exec(base);
64
74
  if (shadowMatch && COLOR_TOKEN.test(shadowMatch[1]))
65
75
  return true;
66
- const ringMatch = /^ring-(.+)$/.exec(base);
76
+ const ringMatch = /^(?:inset-)?ring-(.+)$/.exec(base);
67
77
  if (ringMatch && COLOR_TOKEN.test(ringMatch[1]))
68
78
  return true;
69
79
  return false;
70
80
  }
81
+ // ring-offset-{color} (e.g. ring-offset-blue-500) sets only the
82
+ // --tw-ring-offset-color CSS variable -- the offset ring itself is only
83
+ // ever drawn when a real ring width is *also* present (ring-2, etc.),
84
+ // verified against a real Tailwind v4 build. MODIFIER_ONLY above already
85
+ // excludes the numeric width form (ring-offset-4), but its regex requires
86
+ // digits after "offset-", so it never matched this color-shaped form --
87
+ // caught in independent adversarial testing (a real false negative: this
88
+ // fell through to the generic ring-* prefix match at the bottom of
89
+ // isReplacement and was silently accepted as a real replacement). One
90
+ // level deeper than isColorOnlyShadowOrRing above, so a separate check
91
+ // rather than folding into it.
92
+ function isColorOnlyRingOffset(base) {
93
+ const match = /^ring-offset-(.+)$/.exec(base);
94
+ return !!match && COLOR_TOKEN.test(match[1]);
95
+ }
71
96
  function baseUtility(raw) {
72
97
  return raw.slice(raw.lastIndexOf(":") + 1);
73
98
  }
@@ -79,7 +104,16 @@ function isReplacement(raw) {
79
104
  return false;
80
105
  if (isColorOnlyShadowOrRing(base))
81
106
  return false;
82
- return /^(ring|border|shadow|bg|outline)(-|$)/.test(base);
107
+ if (isColorOnlyRingOffset(base))
108
+ return false;
109
+ // inset-shadow-*/inset-ring-* (Tailwind v4) are their own prefix
110
+ // families, not `ring`/`shadow` with a suffix -- a string starting with
111
+ // "inset-" doesn't match the "shadow"/"ring" alternatives below at all,
112
+ // so a real inset-shadow-sm/inset-ring-2 replacement was previously
113
+ // rejected outright (a false positive on the overall check: it reported
114
+ // the outline as removed with nothing put back, when something real
115
+ // was). Caught in independent adversarial testing.
116
+ return /^(ring|border|shadow|bg|outline|inset-shadow|inset-ring)(-|$)/.test(base);
83
117
  }
84
118
  export function checkFocusIndicators(checks) {
85
119
  const violations = [];
@@ -1,11 +1,20 @@
1
1
  import type { ReducedMotionCheck } from "../parser/extractReducedMotion.js";
2
- export interface ReducedMotionViolation {
2
+ export type ReducedMotionViolation = {
3
3
  type: "reduced-motion";
4
+ mechanism: "transition";
4
5
  file: string;
5
6
  line: number;
6
7
  tagName: string;
7
8
  transitionClass: string;
8
9
  motionClass: string;
9
10
  level: "AAA";
10
- }
11
+ } | {
12
+ type: "reduced-motion";
13
+ mechanism: "animate";
14
+ file: string;
15
+ line: number;
16
+ tagName: string;
17
+ motionClass: string;
18
+ level: "AAA";
19
+ };
11
20
  export declare function checkReducedMotion(checks: ReducedMotionCheck[], strict?: boolean): ReducedMotionViolation[];
@@ -44,6 +44,17 @@ function isNonIdentityMotionUtility(base) {
44
44
  return Number(skew[2]) !== 0;
45
45
  return false;
46
46
  }
47
+ // Verified against a real Tailwind v4 build's compiled keyframes:
48
+ // animate-spin -> rotate(360deg) (orientation/shape change), animate-ping ->
49
+ // scale(2)+opacity:0 (includes a size change), animate-bounce ->
50
+ // translateY(-25%) (position change) -- all three qualify as "motion
51
+ // animation" per this project's own WCAG 2.3.3 boundary (size/shape/
52
+ // position, not color/opacity/blur). animate-pulse is opacity-only (excluded,
53
+ // same reason color/opacity transitions don't count above) and animate-none
54
+ // is the off/identity value (excluded, same treatment as scale-100/rotate-0).
55
+ // A fully enumerated set, not a regex -- unlike scale/rotate/translate/skew,
56
+ // none of these utilities take an arbitrary numeric value to range-check.
57
+ const ANIMATE_MOTION_BASES = new Set(["animate-spin", "animate-ping", "animate-bounce"]);
47
58
  // `strict` gates the whole check for the scan-everything-by-default
48
59
  // adapters (CLI/VS Code/GitHub Action) -- WCAG 2.3.3 is AAA-only, and
49
60
  // unconditionally enabling a brand-new AAA check would silently start
@@ -60,22 +71,93 @@ export function checkReducedMotion(checks, strict = false) {
60
71
  return [];
61
72
  const violations = [];
62
73
  for (const check of checks) {
63
- let unscopedTransition = null;
74
+ let realTransition = null;
64
75
  let hasMotionReduceGuard = false;
65
76
  for (const raw of check.classes) {
66
77
  const base = baseUtility(raw);
67
78
  const segments = variantSegments(raw);
68
- if (segments.length === 0 && TRANSITION_BASES.has(base))
69
- unscopedTransition = raw;
70
- if (segments.includes("motion-reduce") && (base === "transition-none" || base === "transform-none")) {
79
+ // A transition counts as "real" (needs checking) unless its variant
80
+ // stack makes it not actually apply at the moment the interaction
81
+ // begins:
82
+ // - an interaction pseudo-class (hover:/focus:/focus-within:/
83
+ // active:) anywhere in the stack means the transition-property
84
+ // only exists *during* that momentary state, not before it -- CSS
85
+ // has nothing to transition *from* right as the interaction
86
+ // starts, so `hover:transition-transform hover:scale-110` still
87
+ // snaps instantly, same as before this fix (this is the one case
88
+ // the original `segments.length === 0` check happened to get
89
+ // right, so it's preserved here under its real reason instead).
90
+ // - motion-safe: anywhere in the stack means the transition simply
91
+ // doesn't exist unless motion is already safe -- a complete,
92
+ // persistent exemption, unrelated to interaction timing.
93
+ // Any *other* scoping (dark:, sm:, lg:, ...) is a persistent
94
+ // precondition, not a momentary one -- the transition genuinely is
95
+ // present in the resting state whenever that condition holds, with
96
+ // zero relationship to prefers-reduced-motion. Caught in independent
97
+ // adversarial testing: the previous version required
98
+ // `segments.length === 0` (no variant at all), so
99
+ // `dark:transition hover:scale-110` was silently treated as
100
+ // compliant even though it animates on hover in dark mode
101
+ // regardless of the user's motion preference.
102
+ const isInteractionGated = segments.some((v) => INTERACTION_VARIANTS.has(v));
103
+ const isMotionSafeGated = segments.includes("motion-safe");
104
+ if (TRANSITION_BASES.has(base) && !isInteractionGated && !isMotionSafeGated)
105
+ realTransition = raw;
106
+ // Only a *bare* motion-reduce:transition-none/transform-none (no
107
+ // other variant stacked with it) is trusted as a full guard --
108
+ // caught in independent adversarial testing: `sm:motion-reduce:
109
+ // transition-none` was being accepted as fully protective even
110
+ // though it only suppresses the transition at/above the `sm`
111
+ // breakpoint, leaving it completely unguarded below that width.
112
+ // Correctly modeling arbitrary variant-subset relationships (does
113
+ // this guard's other conditions always hold whenever the real
114
+ // trigger's conditions hold?) is out of scope -- requiring the
115
+ // guard to be unconditional is the same "skip/flag rather than
116
+ // guess" posture used everywhere else in this project, erring
117
+ // toward a false positive over the worse failure mode, a false
118
+ // negative.
119
+ if (segments.length === 1 && segments[0] === "motion-reduce" && (base === "transition-none" || base === "transform-none")) {
71
120
  hasMotionReduceGuard = true;
72
121
  }
73
122
  }
74
- // A transition scoped only under motion-safe: (never unscoped) means it
75
- // simply doesn't exist unless motion is already safe -- a complete
123
+ // Independent detection path for the animate-* mechanism -- deliberately
124
+ // NOT nested after the transition path's `continue`s below, since
125
+ // `hover:animate-bounce` alone has no transition base at all
126
+ // (realTransition stays null), which would skip past this block entirely
127
+ // if it were placed after the `if (!realTransition) continue;` line. A
128
+ // single element can have a real violation on both mechanisms at once
129
+ // (independently pushed), or on just one -- they don't race for one slot.
130
+ const hasMotionReduceAnimateGuard = check.classes.some((raw) => {
131
+ const segments = variantSegments(raw);
132
+ return segments.length === 1 && segments[0] === "motion-reduce" && baseUtility(raw) === "animate-none";
133
+ });
134
+ const animateMotionClass = check.classes.find((raw) => {
135
+ const segments = variantSegments(raw);
136
+ // Same self-guard and interaction-scoping reasoning as the transition
137
+ // side's motionClass find below -- see its comment for the full
138
+ // explanation of why both checks are per-candidate, not per-element.
139
+ if (segments.includes("motion-safe"))
140
+ return false;
141
+ if (!segments.some((v) => INTERACTION_VARIANTS.has(v)))
142
+ return false;
143
+ return ANIMATE_MOTION_BASES.has(baseUtility(raw));
144
+ });
145
+ if (animateMotionClass && !hasMotionReduceAnimateGuard) {
146
+ violations.push({
147
+ type: "reduced-motion",
148
+ mechanism: "animate",
149
+ file: check.file,
150
+ line: check.line,
151
+ tagName: check.tagName,
152
+ motionClass: animateMotionClass,
153
+ level: "AAA",
154
+ });
155
+ }
156
+ // No real (non-motion-safe-guarded) transition at all means it simply
157
+ // doesn't exist unless motion is already safe -- a complete
76
158
  // alternative way of satisfying 2.3.3, not a partial one -- so this
77
159
  // correctly falls through as a pass, not a skip-because-unresolvable.
78
- if (!unscopedTransition)
160
+ if (!realTransition)
79
161
  continue;
80
162
  if (hasMotionReduceGuard)
81
163
  continue;
@@ -97,10 +179,11 @@ export function checkReducedMotion(checks, strict = false) {
97
179
  continue; // no real, un-self-guarded motion actually triggered by interaction
98
180
  violations.push({
99
181
  type: "reduced-motion",
182
+ mechanism: "transition",
100
183
  file: check.file,
101
184
  line: check.line,
102
185
  tagName: check.tagName,
103
- transitionClass: unscopedTransition,
186
+ transitionClass: realTransition,
104
187
  motionClass,
105
188
  level: "AAA",
106
189
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tailwind-a11y",
3
- "version": "0.13.1",
3
+ "version": "0.14.0",
4
4
  "description": "Static analysis CLI that catches WCAG accessibility violations — color contrast, touch target size, and focus indicator removal/contrast — in Tailwind CSS class combinations before they ship.",
5
5
  "type": "module",
6
6
  "bin": {