|
| 1 | +--- |
| 2 | +name: tailwind-syntax-state-modifiers |
| 3 | +description: > |
| 4 | + Use when styling element states (form input validity, checked, disabled, |
| 5 | + required, placeholder visibility), structural positions (first, last, |
| 6 | + even, odd, nth-child), pseudo-elements (before, after, placeholder, file, |
| 7 | + marker, selection, first-letter), or motion-preference (motion-safe, |
| 8 | + motion-reduce), or when wiring a Tailwind class to a CSS pseudo-element |
| 9 | + with the content utility. Prevents the empty-before-pseudo trap (the |
| 10 | + ::before disappears when Preflight is disabled), the structural-variant |
| 11 | + scope mistake (first: applied to grandchildren when it should be on |
| 12 | + direct children), the placeholder-styling mistake (placeholder: on the |
| 13 | + wrapper instead of the input), the form-state target mistake (invalid: |
| 14 | + triggers before user input via :user-invalid in v4), and the dialog/details |
| 15 | + open-state confusion (open: works for both via the :is() compound). |
| 16 | + Covers structural pseudo-class variants, all pseudo-element variants, the |
| 17 | + content-[...] utility for ::before and ::after, motion-preference media |
| 18 | + query variants, the full form-state variant set, and target/open variants. |
| 19 | + Keywords: tailwind state, first-child, last-child, nth-child, odd, even, |
| 20 | + before, after, placeholder, content-[], content utility, motion-safe, |
| 21 | + motion-reduce, prefers-reduced-motion, required, valid, invalid, |
| 22 | + user-valid, user-invalid, disabled, checked, indeterminate, autofill, |
| 23 | + read-only, placeholder-shown, in-range, out-of-range, target, open, |
| 24 | + details open, dialog open, empty, first-letter, first-line, marker, |
| 25 | + selection, backdrop, file-selector-button, my ::before is not showing, |
| 26 | + content empty string, preflight disabled, why is first: not working, how |
| 27 | + do I style placeholder text, how do I style disabled input, how to style |
| 28 | + checked checkbox, pseudo element variant, structural variant. |
| 29 | +license: MIT |
| 30 | +compatibility: "Designed for Claude Code. Requires Tailwind CSS v3.4 or v4.0+." |
| 31 | +metadata: |
| 32 | + author: OpenAEC-Foundation |
| 33 | + version: "1.0" |
| 34 | +--- |
| 35 | + |
| 36 | +# Tailwind CSS State Modifiers |
| 37 | + |
| 38 | +This skill covers variants that target CSS states and pseudo-elements other |
| 39 | +than interaction states (`hover`, `focus`, `active`). Use it together with : |
| 40 | + |
| 41 | +- `tailwind-syntax-variants` : full variant grammar including hover/focus, |
| 42 | + group/peer, attribute, and arbitrary variants |
| 43 | +- `tailwind-syntax-responsive` : breakpoint and container-query variants |
| 44 | +- `tailwind-syntax-dark-mode` : the `dark:` variant configuration |
| 45 | + |
| 46 | +## Quick Reference : Variant Categories |
| 47 | + |
| 48 | +| Category | Examples | |
| 49 | +|----------|----------| |
| 50 | +| Structural position | `first:`, `last:`, `only:`, `odd:`, `even:`, `first-of-type:`, `last-of-type:`, `only-of-type:`, `nth-[...]:`, `empty:` | |
| 51 | +| Pseudo-elements | `before:`, `after:`, `placeholder:`, `file:`, `marker:`, `selection:`, `first-letter:`, `first-line:`, `backdrop:` | |
| 52 | +| Motion preference | `motion-safe:`, `motion-reduce:` | |
| 53 | +| Form input state | `required:`, `optional:`, `valid:`, `invalid:`, `user-valid:`, `user-invalid:`, `disabled:`, `enabled:`, `checked:`, `indeterminate:`, `default:`, `in-range:`, `out-of-range:`, `placeholder-shown:`, `autofill:`, `read-only:` | |
| 54 | +| Target / Open | `target:`, `open:` | |
| 55 | + |
| 56 | +All variants in this skill are SHARED between v3 and v4 unless flagged |
| 57 | +otherwise. The one divergence : `user-valid:` and `user-invalid:` are |
| 58 | +v4-only (CSS-native, gated on browser support in v3). |
| 59 | + |
| 60 | +## Structural Variants : Position in Parent |
| 61 | + |
| 62 | +```html |
| 63 | +<ul> |
| 64 | + <li class="first:pt-0">A</li> <!-- &:first-child --> |
| 65 | + <li>B</li> |
| 66 | + <li>C</li> |
| 67 | + <li class="last:pb-0">D</li> <!-- &:last-child --> |
| 68 | +</ul> |
| 69 | + |
| 70 | +<ul> |
| 71 | + <li class="odd:bg-gray-50 even:bg-white">A</li> <!-- &:nth-child(odd/even) --> |
| 72 | + <li class="odd:bg-gray-50 even:bg-white">B</li> |
| 73 | +</ul> |
| 74 | + |
| 75 | +<ul> |
| 76 | + <li class="only:font-bold">Alone</li> <!-- &:only-child --> |
| 77 | +</ul> |
| 78 | + |
| 79 | +<table> |
| 80 | + <tr class="first-of-type:font-bold last-of-type:border-b-0"> |
| 81 | + <td>Row data</td> |
| 82 | + </tr> |
| 83 | +</table> |
| 84 | + |
| 85 | +<div class="empty:hidden"></div> <!-- &:empty (no children, no text) --> |
| 86 | +``` |
| 87 | + |
| 88 | +### Arbitrary `nth-` Selectors |
| 89 | + |
| 90 | +| Variant | Selector | |
| 91 | +|---------|----------| |
| 92 | +| `nth-[3n+1]:` | `&:nth-child(3n+1)` | |
| 93 | +| `nth-last-[3]:` | `&:nth-last-child(3)` | |
| 94 | +| `nth-of-type-[2n]:` | `&:nth-of-type(2n)` | |
| 95 | +| `nth-last-of-type-[odd]:` | `&:nth-last-of-type(odd)` | |
| 96 | +| `nth-[2n+1_of_li]:` | `&:nth-child(2n+1 of li)` | |
| 97 | + |
| 98 | +NEVER apply structural variants to elements that share a parent with |
| 99 | +DIFFERENT element types unless you want the variant to count ALL siblings. |
| 100 | +`first-of-type:` counts only same-tag siblings ; `first:` counts every kind. |
| 101 | + |
| 102 | +```html |
| 103 | +<!-- "first:" picks the heading even though it's not a li --> |
| 104 | +<div> |
| 105 | + <h2 class="first:text-xl">Heading</h2> |
| 106 | + <ul> |
| 107 | + <li class="first:pt-0">First li (correct)</li> |
| 108 | + <li>Second</li> |
| 109 | + </ul> |
| 110 | +</div> |
| 111 | +``` |
| 112 | + |
| 113 | +## Pseudo-Element Variants |
| 114 | + |
| 115 | +| Variant | Selector | Use case | |
| 116 | +|---------|----------|----------| |
| 117 | +| `before:` | `&::before` | Inject content before element | |
| 118 | +| `after:` | `&::after` | Inject content after element | |
| 119 | +| `placeholder:` | `&::placeholder` | Style `<input>` placeholder text | |
| 120 | +| `file:` | `&::file-selector-button` | Style `<input type="file">` button | |
| 121 | +| `marker:` | `&::marker, & *::marker` | Style `<li>` bullets and ordered numbers | |
| 122 | +| `selection:` | `&::selection` | Style user-selected text | |
| 123 | +| `first-letter:` | `&::first-letter` | Drop-cap | |
| 124 | +| `first-line:` | `&::first-line` | Style first line of text | |
| 125 | +| `backdrop:` | `&::backdrop` | Style native `<dialog>` overlay | |
| 126 | + |
| 127 | +## The Content Utility (before / after) |
| 128 | + |
| 129 | +Both v3 and v4 automatically insert `content: ''` for `before:` and `after:` |
| 130 | +WHEN PREFLIGHT IS ACTIVE. If Preflight is disabled, both versions fail |
| 131 | +silently : the pseudo-element does not render. This is the single most |
| 132 | +common before/after bug. |
| 133 | + |
| 134 | +```html |
| 135 | +<!-- Works with preflight on (both v3 and v4) --> |
| 136 | +<span class="before:ml-1 before:inline-block before:size-2 before:bg-red-500"></span> |
| 137 | + |
| 138 | +<!-- Override the default with a string --> |
| 139 | +<span class="before:content-['→'] before:mr-1">Next</span> |
| 140 | + |
| 141 | +<!-- Read from data attribute --> |
| 142 | +<span data-label="New" class="after:content-[attr(data-label)]"></span> |
| 143 | + |
| 144 | +<!-- Empty content (also explicit) --> |
| 145 | +<span class="before:content-['']"></span> |
| 146 | +``` |
| 147 | + |
| 148 | +Source : https://tailwindcss.com/docs/hover-focus-and-other-states |
| 149 | +("Tailwind will automatically add content: '' by default so you don't have |
| 150 | +to specify it unless you want a different value"). |
| 151 | + |
| 152 | +NEVER use a literal space between brackets in `content-[...]`. Whitespace |
| 153 | +is parsed as an underscore. Use `content-['Hello_World']` (Tailwind |
| 154 | +converts `_` to space) or `content-["Hello World"]` with double quotes. |
| 155 | + |
| 156 | +### Preflight-Disabled Workaround |
| 157 | + |
| 158 | +When `preflight: false` (v3) or `@import "tailwindcss/utilities";` only |
| 159 | +(v4 without the preflight import), you MUST add `content-['']` explicitly |
| 160 | +to every `before:` / `after:` usage : |
| 161 | + |
| 162 | +```html |
| 163 | +<!-- Preflight off : explicit content required --> |
| 164 | +<span class="before:content-[''] before:ml-1 before:inline-block before:size-2 before:bg-red-500"></span> |
| 165 | +``` |
| 166 | + |
| 167 | +## Form State Variants |
| 168 | + |
| 169 | +```html |
| 170 | +<input class="required:border-red-500" required /> |
| 171 | +<input class="optional:border-gray-300" /> |
| 172 | + |
| 173 | +<!-- HTML5 validation : :valid / :invalid match BEFORE first interaction --> |
| 174 | +<input type="email" required |
| 175 | + class="invalid:border-red-500 invalid:text-red-700" /> |
| 176 | + |
| 177 | +<!-- v4 only : user-* variants match only AFTER user has interacted --> |
| 178 | +<input type="email" required |
| 179 | + class="user-invalid:border-red-500 user-invalid:text-red-700" /> |
| 180 | + |
| 181 | +<input type="checkbox" class="checked:bg-blue-500" /> |
| 182 | +<input type="checkbox" class="indeterminate:bg-gray-400" /> |
| 183 | + |
| 184 | +<input type="radio" class="default:ring-2 default:ring-blue-500" /> |
| 185 | + |
| 186 | +<input type="text" disabled class="disabled:opacity-50 disabled:cursor-not-allowed" /> |
| 187 | +<input type="text" readonly class="read-only:bg-gray-50" /> |
| 188 | + |
| 189 | +<input class="placeholder-shown:border-gray-300" placeholder="Type here" /> |
| 190 | + |
| 191 | +<input type="text" class="autofill:bg-yellow-50" /> |
| 192 | + |
| 193 | +<input type="number" min="0" max="100" |
| 194 | + class="in-range:border-green-500 out-of-range:border-red-500" /> |
| 195 | +``` |
| 196 | + |
| 197 | +### Variant Selectors |
| 198 | + |
| 199 | +| Variant | Selector | |
| 200 | +|---------|----------| |
| 201 | +| `required` | `&:required` | |
| 202 | +| `optional` | `&:optional` | |
| 203 | +| `valid` | `&:valid` (matches before first interaction) | |
| 204 | +| `invalid` | `&:invalid` (matches before first interaction) | |
| 205 | +| `user-valid` | `&:user-valid` (v4 only ; matches after user input) | |
| 206 | +| `user-invalid` | `&:user-invalid` (v4 only) | |
| 207 | +| `disabled` | `&:disabled` | |
| 208 | +| `enabled` | `&:enabled` | |
| 209 | +| `checked` | `&:checked` | |
| 210 | +| `indeterminate` | `&:indeterminate` | |
| 211 | +| `default` | `&:default` (form input pre-selected by browser) | |
| 212 | +| `in-range` | `&:in-range` (number/range inputs) | |
| 213 | +| `out-of-range` | `&:out-of-range` | |
| 214 | +| `placeholder-shown` | `&:placeholder-shown` | |
| 215 | +| `autofill` | `&:autofill` | |
| 216 | +| `read-only` | `&:read-only` | |
| 217 | + |
| 218 | +ALWAYS prefer `user-invalid:` over `invalid:` in v4 when styling field |
| 219 | +errors : `invalid:` fires on every required field as soon as the page |
| 220 | +loads, before the user has typed anything. `user-invalid:` waits for |
| 221 | +interaction. v3 has no `user-*` variants : work around with the |
| 222 | +`:placeholder-shown` trick or wire validation via JS. |
| 223 | + |
| 224 | +```html |
| 225 | +<!-- v3 trick : invalid only matters after user typed something --> |
| 226 | +<input type="email" required |
| 227 | + class="invalid:not-placeholder-shown:border-red-500" |
| 228 | + placeholder=" " /> |
| 229 | +``` |
| 230 | + |
| 231 | +## Motion Preference Variants |
| 232 | + |
| 233 | +```html |
| 234 | +<!-- Animation only when user has NOT set reduce-motion preference --> |
| 235 | +<div class="motion-safe:animate-spin">spinner</div> |
| 236 | + |
| 237 | +<!-- Static fallback when user prefers reduced motion --> |
| 238 | +<div class="motion-safe:animate-bounce motion-reduce:opacity-50"></div> |
| 239 | +``` |
| 240 | + |
| 241 | +| Variant | Media query | |
| 242 | +|---------|-------------| |
| 243 | +| `motion-safe` | `@media (prefers-reduced-motion: no-preference)` | |
| 244 | +| `motion-reduce` | `@media (prefers-reduced-motion: reduce)` | |
| 245 | + |
| 246 | +ALWAYS gate animations with `motion-safe:` OR provide a `motion-reduce:` |
| 247 | +override. NEVER ship `animate-spin` unconditionally on critical UI : users |
| 248 | +with vestibular conditions can be physically hurt by it. |
| 249 | + |
| 250 | +## Target Variant : URL Fragment |
| 251 | + |
| 252 | +```html |
| 253 | +<a href="#section-2">Jump</a> |
| 254 | + |
| 255 | +<section id="section-2" class="target:bg-yellow-100"> |
| 256 | + Section 2 (highlights when URL fragment is #section-2). |
| 257 | +</section> |
| 258 | +``` |
| 259 | + |
| 260 | +`target:` matches when the element's id equals the current URL fragment. |
| 261 | +Useful for in-page anchor highlighting without JS. |
| 262 | + |
| 263 | +## Open Variant : details / dialog / popover |
| 264 | + |
| 265 | +```html |
| 266 | +<details class="open:bg-gray-50"> |
| 267 | + <summary>Click to expand</summary> |
| 268 | + <p>Hidden until summary is clicked.</p> |
| 269 | +</details> |
| 270 | + |
| 271 | +<dialog id="d" class="backdrop:bg-black/30 open:p-4"> |
| 272 | + <p>Modal content</p> |
| 273 | +</dialog> |
| 274 | + |
| 275 | +<div popover class="open:shadow-lg">popover content</div> |
| 276 | +``` |
| 277 | + |
| 278 | +The v4 `open:` variant compiles to `&:is([open], :popover-open, :open)` so |
| 279 | +it matches `<details>`, `<dialog>`, and `[popover]` elements simultaneously. |
| 280 | +The v3 `open:` compiles to `&[open]` only and does NOT match popovers. |
| 281 | + |
| 282 | +NEVER assume `open:` matches modals you open via `dialog.showModal()` : |
| 283 | +that adds the `[open]` attribute, which IS matched. Programmatically |
| 284 | +showing a `<dialog>` via `.show()` also sets `[open]`. |
| 285 | + |
| 286 | +## Empty Variant |
| 287 | + |
| 288 | +```html |
| 289 | +<div class="empty:hidden"> |
| 290 | + <!-- if this div has zero children AND zero text, it is display:none --> |
| 291 | +</div> |
| 292 | +``` |
| 293 | + |
| 294 | +`empty:` matches both no-child and no-text states. Whitespace counts as |
| 295 | +text, so a `<div> </div>` is NOT empty. Use `empty:hidden` to collapse |
| 296 | +list placeholders that may receive no items. |
| 297 | + |
| 298 | +## Decision Tree : Which Variant Group? |
| 299 | + |
| 300 | +``` |
| 301 | +Styling based on what? |
| 302 | +├── Position among siblings → structural (first/last/nth-) |
| 303 | +├── A pseudo-element (::before, ::placeholder, ::marker, etc.) → pseudo-element variants |
| 304 | +├── A form input attribute state (required, disabled, checked) → form state |
| 305 | +├── HTML5 validity (valid, invalid) → form-state, prefer user-valid/user-invalid in v4 |
| 306 | +├── User accessibility preference (reduced motion) → motion-safe/motion-reduce |
| 307 | +├── URL fragment match → target: |
| 308 | +├── details/dialog/popover open → open: |
| 309 | +├── No children → empty: |
| 310 | +└── User pointer interaction (hover, focus) → see tailwind-syntax-variants |
| 311 | +``` |
| 312 | + |
| 313 | +## Stacking With Other Variants |
| 314 | + |
| 315 | +```html |
| 316 | +<!-- Position + hover --> |
| 317 | +<li class="first:hover:bg-gray-100"></li> |
| 318 | + |
| 319 | +<!-- Position + responsive --> |
| 320 | +<li class="first:pt-0 md:first:pt-4"></li> |
| 321 | + |
| 322 | +<!-- Form state + dark --> |
| 323 | +<input class="invalid:border-red-500 dark:invalid:border-red-300" /> |
| 324 | + |
| 325 | +<!-- Pseudo-element + group --> |
| 326 | +<div class="group"> |
| 327 | + <span class="before:opacity-0 group-hover:before:opacity-100">→</span> |
| 328 | +</div> |
| 329 | +``` |
| 330 | + |
| 331 | +v4 stacks chains left-to-right (per upgrade guide). The functional intent |
| 332 | +is the same as v3 ; only the syntax order differs. See `tailwind-syntax-variants` |
| 333 | +for the stacking-order migration. |
| 334 | + |
| 335 | +## Reference Files |
| 336 | + |
| 337 | +- `references/methods.md` : every variant with full CSS selector and |
| 338 | + browser-support note |
| 339 | +- `references/examples.md` : full real-world patterns (validated form, |
| 340 | + drop-cap, custom bullet, modal backdrop, accessible spinner) |
| 341 | +- `references/anti-patterns.md` : the empty-before-with-preflight-off |
| 342 | + trap, `invalid:` firing before user input, `placeholder:` on wrapper |
| 343 | + instead of input, `first:` counting wrong sibling type |
| 344 | + |
| 345 | +## Verified Sources |
| 346 | + |
| 347 | +- https://tailwindcss.com/docs/hover-focus-and-other-states (v4) |
| 348 | +- https://v3.tailwindcss.com/docs/hover-focus-and-other-states (v3) |
| 349 | +- vooronderzoek-tailwind.md §6 Variant System |
0 commit comments