+
*]="mt-2"
+ className:data-[state=open]="ring-2 ring-emerald-400"
>
- Hover the card · resize the container for @md
+ Hover · @md · toggle isActive for data-state
diff --git a/src/core.ts b/src/core.ts
index dfa6e69..ee10942 100644
--- a/src/core.ts
+++ b/src/core.ts
@@ -14,34 +14,194 @@ export const SUPPORTED_FILES = [
/**
* Variant names in `class:…` / `className:…`.
- * Includes `/` (named groups like `group-hover/item`) and `@` (container
- * queries like `@md`). Arbitrary variants (`[&>*]`, `data-[open]`) still cannot
- * be attribute names — `[` / `]` / `&` are not allowed. React authors still write
- * `className:@md` / `className:group-hover/item`; UseClassy rewrites them before
- * JSX parse (same pipeline as `className:sm:hover`).
+ * Simple names: letters, digits, `_`, `-`, `:`, `/`, `@`.
+ * Arbitrary variants (`[&>*]`, `data-[state=open]`) are matched with
+ * bracket-aware scanning (not this character class alone) so `=` inside
+ * `[…]` is not treated as the attribute separator.
*/
export const CLASS_MODIFIER_NAME_PATTERN = String.raw`[\w/:@-]+`
-/** Base (Vue) class attribute regexes */
+/** Prefixes that introduce a UseClassy modifier attribute. */
+export type ClassModifierPrefix = 'class:' | 'className:'
+
+/**
+ * Reads a modifier name starting at `start` (first character of the name).
+ * Bracket depth keeps `=` inside `[…]` as part of the name
+ * (`data-[state=open]`), stopping only at depth 0 before `=` / whitespace / EOS.
+ */
+export function readModifierName(
+ code: string,
+ start: number,
+): { modifiers: string, endIndex: number } | null {
+ if (start >= code.length)
+ return null
+
+ let i = start
+ let depth = 0
+
+ while (i < code.length) {
+ const ch = code[i]!
+
+ if (ch === '[') {
+ depth++
+ i++
+ continue
+ }
+
+ if (ch === ']') {
+ if (depth === 0)
+ return null
+ depth--
+ i++
+ continue
+ }
+
+ if (depth > 0) {
+ // Inside […] allow anything (including `=`, quotes, `&`, `*`, `>`).
+ i++
+ continue
+ }
+
+ // Depth 0: classic variant characters, or `[` handled above.
+ if (
+ (ch >= 'a' && ch <= 'z')
+ || (ch >= 'A' && ch <= 'Z')
+ || (ch >= '0' && ch <= '9')
+ || ch === '_'
+ || ch === '-'
+ || ch === ':'
+ || ch === '/'
+ || ch === '@'
+ ) {
+ i++
+ continue
+ }
+
+ break
+ }
+
+ if (i === start || depth !== 0)
+ return null
+
+ return {
+ modifiers: code.slice(start, i),
+ endIndex: i,
+ }
+}
+
+function modifierPrefixesForAttr(classAttrName: string): ClassModifierPrefix[] {
+ return classAttrName === 'className'
+ ? ['className:', 'class:']
+ : ['class:']
+}
+
+function modifierPrefixesForRegex(classModifierRegex: RegExp): ClassModifierPrefix[] {
+ if (
+ classModifierRegex === REACT_CLASS_MODIFIER_REGEX
+ || classModifierRegex.source.includes('className')
+ ) {
+ return ['className:', 'class:']
+ }
+ return ['class:']
+}
+
+/**
+ * Finds `class:mod="…"` / `className:mod="…"` with bracket-aware modifier names.
+ */
+export function forEachQuotedClassModifier(
+ code: string,
+ prefixes: readonly ClassModifierPrefix[],
+ callback: (match: {
+ fullStart: number
+ fullEnd: number
+ modifiers: string
+ classes: string
+ }) => void,
+): void {
+ let searchFrom = 0
+
+ while (searchFrom < code.length) {
+ let foundAt = -1
+ let foundPrefix: ClassModifierPrefix | null = null
+
+ for (const prefix of prefixes) {
+ let from = searchFrom
+ while (from < code.length) {
+ const idx = code.indexOf(prefix, from)
+ if (idx === -1)
+ break
+ if (isClassAttrNameBoundary(code, idx)) {
+ if (foundAt === -1 || idx < foundAt) {
+ foundAt = idx
+ foundPrefix = prefix
+ }
+ break
+ }
+ from = idx + 1
+ }
+ }
+
+ if (foundAt === -1 || !foundPrefix)
+ break
+
+ const nameStart = foundAt + foundPrefix.length
+ const name = readModifierName(code, nameStart)
+ if (!name) {
+ searchFrom = nameStart
+ continue
+ }
+
+ let i = name.endIndex
+ while (i < code.length && /\s/.test(code[i]!))
+ i++
+
+ if (code[i] !== '=' || code[i + 1] !== '"') {
+ searchFrom = name.endIndex
+ continue
+ }
+
+ const valueStart = i + 2
+ let j = valueStart
+ while (j < code.length) {
+ if (code[j] === '\\' && j + 1 < code.length) {
+ j += 2
+ continue
+ }
+ if (code[j] === '"')
+ break
+ j++
+ }
+
+ if (j >= code.length) {
+ searchFrom = name.endIndex
+ continue
+ }
+
+ callback({
+ fullStart: foundAt,
+ fullEnd: j + 1,
+ modifiers: name.modifiers,
+ classes: code.slice(valueStart, j),
+ })
+
+ searchFrom = j + 1
+ }
+}
+
+/** Base (Vue) class attribute regexes — simple names only; prefer scanners for full support. */
export const CLASS_REGEX = /(?]*:class)/g
export const CLASS_MODIFIER_REGEX = new RegExp(
String.raw`(?]*:)/g
export const REACT_CLASS_MODIFIER_REGEX = new RegExp(
String.raw`(? void,
): void {
- JSX_MODIFIER_START_REGEX.lastIndex = 0
- let startMatch: RegExpExecArray | null
- while ((startMatch = JSX_MODIFIER_START_REGEX.exec(code)) !== null) {
- const modifiers = startMatch[1]
- const openBraceIndex = startMatch.index + startMatch[0].length - 1
- const balanced = readBalancedJsxExpression(code, openBraceIndex)
- if (!balanced || !modifiers) {
- // Avoid tight loops on malformed `{` without a closing brace.
- JSX_MODIFIER_START_REGEX.lastIndex = openBraceIndex + 1
+ // Prefer longer prefix first so `className:` wins over `class:`.
+ const prefixes: ClassModifierPrefix[] = ['className:', 'class:']
+ let searchFrom = 0
+
+ while (searchFrom < code.length) {
+ let foundAt = -1
+ let foundPrefix: ClassModifierPrefix | null = null
+
+ for (const prefix of prefixes) {
+ let from = searchFrom
+ while (from < code.length) {
+ const idx = code.indexOf(prefix, from)
+ if (idx === -1)
+ break
+ if (isClassAttrNameBoundary(code, idx)) {
+ if (foundAt === -1 || idx < foundAt) {
+ foundAt = idx
+ foundPrefix = prefix
+ }
+ break
+ }
+ from = idx + 1
+ }
+ }
+
+ if (foundAt === -1 || !foundPrefix)
+ break
+
+ const nameStart = foundAt + foundPrefix.length
+ const name = readModifierName(code, nameStart)
+ if (!name) {
+ searchFrom = nameStart
+ continue
+ }
+
+ let i = name.endIndex
+ while (i < code.length && /\s/.test(code[i]!))
+ i++
+
+ if (code[i] !== '=') {
+ searchFrom = name.endIndex
+ continue
+ }
+ i++
+ while (i < code.length && /\s/.test(code[i]!))
+ i++
+
+ if (code[i] !== '{') {
+ // Quoted modifiers are handled separately; skip `="…"`.
+ searchFrom = name.endIndex
+ continue
+ }
+
+ const balanced = readBalancedJsxExpression(code, i)
+ if (!balanced) {
+ searchFrom = i + 1
continue
}
callback({
- fullStart: startMatch.index,
+ fullStart: foundAt,
fullEnd: balanced.endIndex + 1,
- modifiers,
+ modifiers: name.modifiers,
expression: balanced.content,
})
- JSX_MODIFIER_START_REGEX.lastIndex = balanced.endIndex + 1
+ searchFrom = balanced.endIndex + 1
}
}
@@ -506,18 +713,19 @@ export function extractClasses(
}
}
- let modifierMatch
- while ((modifierMatch = classModifierRegex.exec(code)) !== null) {
- const modifiers = modifierMatch[1]
- const classes = modifierMatch[2]
-
- if (modifiers && classes) {
+ // Quoted modifiers — bracket-aware so `data-[state=open]` keeps its inner `=`.
+ forEachQuotedClassModifier(
+ code,
+ modifierPrefixesForRegex(classModifierRegex),
+ ({ modifiers, classes }) => {
+ if (!modifiers.trim() || !classes)
+ return
for (const modifiedClass of buildModifiedClasses(classes, modifiers)) {
allFileClasses.add(modifiedClass)
modifierDerivedClasses.add(modifiedClass)
}
- }
- }
+ },
+ )
// Conditional / JSX expression modifiers: className:hover={cond ? 'a' : 'b'}
forEachJsxModifier(code, ({ modifiers, expression }) => {
@@ -546,8 +754,12 @@ export function transformClassModifiers(
classModifierRegex: RegExp,
classAttrName: string,
): string {
- const withStaticModifiers = code.replace(classModifierRegex, (match, modifiers, classes) => {
- if (!modifiers?.trim()) return match
+ const prefixes = modifierPrefixesForAttr(classAttrName)
+ const replacements: Array<{ start: number, end: number, text: string }> = []
+
+ forEachQuotedClassModifier(code, prefixes, ({ fullStart, fullEnd, modifiers, classes }) => {
+ if (!modifiers?.trim())
+ return
const modifiedClassesArr = buildModifiedClasses(classes, modifiers)
@@ -556,9 +768,26 @@ export function transformClassModifiers(
generatedClassesSet.add(cls)
}
- return `${classAttrName}="${modifiedClassesArr.join(' ')}"`
+ replacements.push({
+ start: fullStart,
+ end: fullEnd,
+ text: `${classAttrName}="${modifiedClassesArr.join(' ')}"`,
+ })
})
+ let withStaticModifiers = code
+ for (let i = replacements.length - 1; i >= 0; i--) {
+ const replacement = replacements[i]
+ if (!replacement)
+ continue
+ withStaticModifiers = withStaticModifiers.slice(0, replacement.start)
+ + replacement.text
+ + withStaticModifiers.slice(replacement.end)
+ }
+
+ // classModifierRegex retained for API compatibility (language detection in callers).
+ void classModifierRegex
+
return transformJsxExpressionModifiers(
withStaticModifiers,
generatedClassesSet,
diff --git a/src/init/agents.ts b/src/init/agents.ts
index eb73127..15e01b1 100644
--- a/src/init/agents.ts
+++ b/src/init/agents.ts
@@ -66,7 +66,7 @@ This project uses \`vite-plugin-useclassy\`. Write Tailwind variants as modifier
attributes instead of inline variant prefixes:
- Vue, Svelte, Blade, HTML: \`class="rounded px-4" class:hover="bg-blue-500"\`
-- React: \`className="rounded px-4" className:hover="bg-blue-500"\` (JSX expressions with string literals are also supported, e.g. \`className:hover={on ? 'a' : 'b'}\`). Same spelling for container queries and named groups: \`className:@md\` / \`className:group-hover/item\` (UseClassy rewrites before JSX parse).
+- React: \`className="rounded px-4" className:hover="bg-blue-500"\` (JSX expressions with string literals are also supported, e.g. \`className:hover={on ? 'a' : 'b'}\`). Same spelling for \`@md\`, named groups, and arbitrary variants: \`className:@md\`, \`className:group-hover/item\`, \`className:[&>*]\`, \`className:data-[state=open]\` (UseClassy rewrites before JSX parse).
Leave Vue \`:class\`, native Svelte \`class:name={cond}\` directives, and unrelated
dynamic base expressions unchanged.
diff --git a/src/init/vscode.ts b/src/init/vscode.ts
index b333fc5..8339811 100644
--- a/src/init/vscode.ts
+++ b/src/init/vscode.ts
@@ -3,8 +3,11 @@ import path from 'path'
import type { FilePatchResult, InitLanguage, InitSetupResult } from './types'
-const VSCODE_CLASS_PATTERNS_VUE = ['class:[\\w:/@-]*']
-const VSCODE_CLASS_PATTERNS_REACT = ['class:[\\w:/@-]*', 'className:[\\w:/@-]*']
+const VSCODE_CLASS_PATTERNS_VUE = ['class:[\\w:/@\\[\\]\\-=&*>.]*']
+const VSCODE_CLASS_PATTERNS_REACT = [
+ 'class:[\\w:/@\\[\\]\\-=&*>.]*',
+ 'className:[\\w:/@\\[\\]\\-=&*>.]*',
+]
export function mergeTailwindClassAttributes(
existing: unknown,
diff --git a/src/tests/core.test.ts b/src/tests/core.test.ts
index 8058ed3..9bd9913 100644
--- a/src/tests/core.test.ts
+++ b/src/tests/core.test.ts
@@ -407,6 +407,51 @@ describe('core module', () => {
expect(classes.has('@md:p-4')).toBeTruthy()
})
+ it('transforms arbitrary variants with brackets and inner =', () => {
+ const code
+ = '
*]="mt-2" class:data-[state=open]="block" className:[&_p]:hover="underline">X
'
+ const vueClasses = new Set
()
+ const vueResult = transformClassModifiers(
+ code,
+ vueClasses,
+ CLASS_MODIFIER_REGEX,
+ 'class',
+ )
+ expect(vueResult).toContain('[&>*]:mt-2')
+ expect(vueResult).toContain('data-[state=open]:block')
+ expect(vueResult).not.toContain('class:[&>*]')
+ expect(vueResult).not.toContain('class:data-[state=open]')
+ expect(vueClasses.has('[&>*]:mt-2')).toBeTruthy()
+ expect(vueClasses.has('data-[state=open]:block')).toBeTruthy()
+
+ const reactClasses = new Set()
+ const reactResult = transformClassModifiers(
+ '*]="mt-2" className:data-[state=open]="block">X
',
+ reactClasses,
+ REACT_CLASS_MODIFIER_REGEX,
+ 'className',
+ )
+ expect(reactResult).toContain('[&>*]:mt-2')
+ expect(reactResult).toContain('data-[state=open]:block')
+ expect(reactResult).not.toContain('className:[&>*]')
+ expect(reactClasses.has('data-[state=open]:block')).toBeTruthy()
+ })
+
+ it('transforms JSX expression arbitrary modifiers', () => {
+ const code
+ = `X
`
+ const classes = new Set()
+ const result = transformClassModifiers(
+ code,
+ classes,
+ REACT_CLASS_MODIFIER_REGEX,
+ 'className',
+ )
+ expect(result).toContain('data-[state=open]:block')
+ expect(result).toContain('data-[state=open]:hidden')
+ expect(result).not.toContain('className:data-[state=open]')
+ })
+
it('extracts React className:@md and named-group modifiers', () => {
const code
= 'X
'
diff --git a/src/tests/index.test.ts b/src/tests/index.test.ts
index 3d442a8..0f62f14 100644
--- a/src/tests/index.test.ts
+++ b/src/tests/index.test.ts
@@ -242,6 +242,35 @@ describe('useClassy plugin', () => {
expect(rewritten).not.toContain('className:@md')
expect(rewritten).not.toContain('className:group-hover/item')
})
+
+ it('rewrites arbitrary variant modifiers during Vite 8 dep scan', () => {
+ const plugin = useClassy({ language: 'react' }) as Plugin
+ const config = (
+ plugin.config as (config: object, env: object) => {
+ optimizeDeps: {
+ rolldownOptions: {
+ plugins: Array<{
+ name: string
+ transform: { handler: (code: string) => string | null }
+ }>
+ }
+ }
+ }
+ )({}, { command: 'serve', mode: 'development' })
+
+ const scanPlugin = config.optimizeDeps.rolldownOptions.plugins.find(
+ candidate => candidate.name === 'useClassy:dep-scan',
+ )
+ expect(scanPlugin).toBeDefined()
+
+ const rewritten = scanPlugin!.transform.handler(
+ '*]="mt-2" className:data-[state=open]="block">X
',
+ )
+ expect(rewritten).toContain('[&>*]:mt-2')
+ expect(rewritten).toContain('data-[state=open]:block')
+ expect(rewritten).not.toContain('className:[&>*]')
+ expect(rewritten).not.toContain('className:data-[state=open]')
+ })
})
describe('Basic transformations', () => {
diff --git a/src/tests/init-setup.test.ts b/src/tests/init-setup.test.ts
index 75b3393..17ee3c4 100644
--- a/src/tests/init-setup.test.ts
+++ b/src/tests/init-setup.test.ts
@@ -299,19 +299,19 @@ describe('mergeTailwindClassAttributes', () => {
it('merges vue patterns', () => {
const out = mergeTailwindClassAttributes(['class'], 'vue')
expect(out).toContain('class')
- expect(out).toContain('class:[\\w:/@-]*')
+ expect(out).toContain('class:[\\w:/@\\[\\]\\-=&*>.]*')
})
it('adds className for react', () => {
const out = mergeTailwindClassAttributes([], 'react')
expect(out).toContain('className')
- expect(out).toContain('className:[\\w:/@-]*')
+ expect(out).toContain('className:[\\w:/@\\[\\]\\-=&*>.]*')
})
it('uses vue-style patterns for svelte', () => {
const out = mergeTailwindClassAttributes(['class'], 'svelte')
expect(out).toContain('class')
- expect(out).toContain('class:[\\w:/@-]*')
+ expect(out).toContain('class:[\\w:/@\\[\\]\\-=&*>.]*')
expect(out).not.toContain('className')
})
})
diff --git a/tasks/lessons.md b/tasks/lessons.md
index bc6c45d..260af40 100644
--- a/tasks/lessons.md
+++ b/tasks/lessons.md
@@ -75,10 +75,10 @@
- Do not rewrite UseClassy smoke demos into a polished fictional product UI (Harbor-style inbox, design-system cards, etc.) unless the user has approved a mock after seeing it.
- Coverage pages can stay labeled and a bit clinical; that is easier to scan than a realistic layout that hides the cases. Prefer smaller visual cleanup (copy, titles, spacing) over a full scene rewrite.
-## React `@` / `/` modifiers (2026-08-25)
+## React `@` / `/` / arbitrary modifiers (2026-08-25)
-- JSX cannot parse `@` or `/` in attribute names on its own, but UseClassy rewrites `className:@md` / `className:group-hover/item` before JSX parse — same pipeline as `className:sm:hover`.
-- Do not invent substitute characters (`$md`, `at-md`) and do not add a separate `mods()` API for this; keep the same attribute spelling as Vue.
+- UseClassy rewrites `className:@md`, `className:group-hover/item`, `className:[&>*]`, and `className:data-[state=open]` before JSX/HTML parse — same pipeline as `className:sm:hover`.
+- Parse modifier names with bracket depth so `=` inside `[…]` is not treated as the attribute separator. Do not invent substitute characters or a separate `mods()` helper.
- TypeScript / some linters may still flag the source the same way they already flag chained modifiers.
- When rewriting string literals inside `className:modifier={…}`, never blindly prefix every quoted string.
diff --git a/templates/useclassy-authoring.cursor-rule.mdc b/templates/useclassy-authoring.cursor-rule.mdc
index 175cf72..d7a16ea 100644
--- a/templates/useclassy-authoring.cursor-rule.mdc
+++ b/templates/useclassy-authoring.cursor-rule.mdc
@@ -34,6 +34,6 @@ When this project uses `vite-plugin-useclassy`, write new static Tailwind varian
- React: prefer double-quoted static strings; `className:mod={cond ? 'a' : 'b'}` is also valid when literals should be prefixed.
- Leave Vue `:class`, Svelte native `class:name={cond}`, and unrelated dynamic base expressions unchanged.
- **Svelte**: only transform quoted UseClassy modifiers (`class:hover="…"`). Do not rewrite native `class:name={cond}` or `class:name`.
-- Keep arbitrary variants such as `[&>*]:mt-2` and `data-[state=open]:block` in the base string. Named groups (`group-hover/item`) and `@md` container queries work as modifier attributes in Vue/HTML and React (`className:group-hover/item`, `className:@md`). UseClassy rewrites React attributes before JSX parse (same as `className:sm:hover`).
+- Keep named groups (`group-hover/item`), `@md` container queries, and arbitrary variants (`[&>*]`, `data-[state=open]`) as modifier attributes in Vue/HTML and React. UseClassy rewrites them before parse (bracket-aware so `=` inside `[…]` is fine).
- Chained attributes match Tailwind/Uno composition: `class:sm:hover="underline"` generates `sm:hover:underline` only. Converting `sm:hover:underline` to `class:sm:hover="underline"` is behavior-preserving.
- After refactoring, run formatting and relevant tests/build; verify dynamic classes and rendered states are unchanged.
diff --git a/templates/useclassy-setup.cursor-rule.mdc b/templates/useclassy-setup.cursor-rule.mdc
index 40d3889..590b194 100644
--- a/templates/useclassy-setup.cursor-rule.mdc
+++ b/templates/useclassy-setup.cursor-rule.mdc
@@ -9,7 +9,7 @@ When adding or fixing UseClassy in this project:
2. In `vite.config.*`, import `useClassy` from `vite-plugin-useclassy` and add `useClassy({ language: 'vue' | 'react' | 'blade' | 'svelte' })` to `plugins` **before** `@tailwindcss/vite` or other CSS plugins. For Svelte, also place it **before** `@sveltejs/vite-plugin-svelte`.
3. **Tailwind v4** (CSS uses `@import "tailwindcss"`): In that stylesheet, add an `@source` line pointing at the generated manifest. Default manifest path is `.classy/output.classy.html` from the project root; the `@source` path must be **relative to the CSS file**. In JavaScript configs you can compute the line with `getUseClassyTailwindSourceDirective(cssAbsolutePath, projectRoot)` from `vite-plugin-useclassy`.
4. **Tailwind v3**: Add `./.classy/output.classy.html` to `content` in `tailwind.config.*` (or use `getUseClassyTailwindV3ContentEntry()` from the package for the default path).
-5. **VS Code** (Tailwind only): Merge `tailwindCSS.classAttributes` to include `class:[\\w:/@-]*` and, for React, `className:[\\w:/@-]*`. Skip this for UnoCSS — use the UnoCSS extension.
+5. **VS Code** (Tailwind only): Merge `tailwindCSS.classAttributes` to include `class:[\\w:/@\\[\\]\\-=&*>.]*` and, for React, `className:[\\w:/@\\[\\]\\-=&*>.]*`. Skip this for UnoCSS — use the UnoCSS extension.
6. Run dev once so `.classy/output.classy.html` is generated.
Prefer `npx vite-plugin-useclassy init` (after installing the package) instead of hand-editing when possible. Use `--with-skills` to install the UseClassy authoring skill (`.agents/skills`) plus these Cursor rules. Add `--with-claude` if you also use Claude Code.
diff --git a/templates/useclassy-skill/SKILL.md b/templates/useclassy-skill/SKILL.md
index 25102f7..df0444f 100644
--- a/templates/useclassy-skill/SKILL.md
+++ b/templates/useclassy-skill/SKILL.md
@@ -28,10 +28,10 @@ Use UseClassy to separate Tailwind variants from base utilities:
| Language | Base | Modifiers |
| ----------- | --------------- | ----------------------------------------------------------------------- |
| Vue / Blade | `class="…"` | `class:hover="…"`, `class:sm:hover="…"` |
-| React | `className="…"` | `className:hover="…"`, `className:@md="…"`, `className:group-hover/item="…"`; JSX expressions allowed |
+| React | `className="…"` | `className:hover="…"`, `className:@md="…"`, `className:group-hover/item="…"`, `className:[&>*]="…"`, `className:data-[state=open]="…"`; JSX expressions allowed |
| Svelte | `class="…"` | Quoted only: `class:hover="…"` |
-Modifier names may contain letters, numbers, `_`, `-`, `:`, `/` (named groups such as `group-hover/item`), and `@` (container queries such as `@md`). Arbitrary variants (`[&>*]`, `data-[state=open]`) cannot be attribute names — leave those tokens on the base class. React uses the same modifier attributes as Vue (`className:@md`, `className:group-hover/item`); UseClassy rewrites them before JSX parse (same as `className:sm:hover`).
+Modifier names may contain letters, numbers, `_`, `-`, `:`, `/` (named groups such as `group-hover/item`), `@` (container queries such as `@md`), and arbitrary variants with `[…]` (`[&>*]`, `data-[state=open]`). UseClassy parses modifier names with bracket depth so `=` inside `[…]` is not the attribute separator. React uses the same modifier attributes as Vue; UseClassy rewrites them before JSX/HTML parse.
- **Vue / Blade / Svelte / HTML:** modifier values must be double-quoted static class strings.
- **React:** prefer double-quoted static strings. JSX expressions are also supported when string literals inside the expression should receive the variant prefix, e.g. `className:hover={on ? 'bg-blue-500' : 'bg-gray-200'}`.
@@ -53,7 +53,6 @@ When asked to convert markup to UseClassy:
Convert only static tokens that can be represented safely. Do not rewrite:
- Dynamic expressions, template interpolations, conditional class helpers, Vue `:class`, or Svelte directives — unless you are intentionally using React's `className:mod={…}` expression form with string literals.
-- Arbitrary variant prefixes such as `[&>*]:mt-2` or `data-[state=open]:block`; their characters are not valid in a UseClassy modifier attribute name — leave those on the base class.
- Variant tokens embedded in variables or function calls (leave those variables unchanged, or store already-prefixed class names).
## Chained modifiers
@@ -69,7 +68,7 @@ class:sm:hover="underline"
- Put base utilities on `class` / `className`.
- Vue / Blade / HTML: use `class:modifier="…"`.
-- React: prefer `className:modifier="…"` for static variants, including `className:@md` and `className:group-hover/item` (UseClassy rewrites before JSX parse). For runtime conditions that still use string literals, `className:modifier={cond ? 'a' : 'b'}` is valid and will prefix those literals. Leave `className={…}` base expressions unchanged when they are unrelated.
+- React: prefer `className:modifier="…"` for static variants, including `className:@md`, `className:group-hover/item`, `className:[&>*]`, and `className:data-[state=open]` (UseClassy rewrites before JSX parse). For runtime conditions that still use string literals, `className:modifier={cond ? 'a' : 'b'}` is valid and will prefix those literals. Leave `className={…}` base expressions unchanged when they are unrelated.
- Vue: leave `:class` and other dynamic bindings unchanged.
- **Svelte**: only transform quoted UseClassy modifiers. Native `class:active={cond}` and `class:active` stay untouched — do not rewrite those.
- Do not move conditional base utilities into modifier attributes on Vue/Svelte/Blade; UseClassy modifiers represent Tailwind variants. React is the exception for `className:mod={…}` expression values.
From 92d5255be3c319412bd9c542d88589a0bcfdd56d Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Tue, 25 Aug 2026 21:01:00 +0000
Subject: [PATCH 4/5] docs: sync VS Code classAttributes patterns for arbitrary
variants
Co-authored-by: Jeremy Butler
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 7a06fc0..6de76e2 100644
--- a/README.md
+++ b/README.md
@@ -150,7 +150,7 @@ UseClassy is variant-first (`class:hover="bg-red"`), not [Attributify](https://u
```json
{
- "tailwindCSS.classAttributes": ["class", "class:[\\w:/@-]*", "className", "className:[\\w:/@-]*"]
+ "tailwindCSS.classAttributes": ["class", "class:[\\w:/@\\[\\]\\-=&*>.]*", "className", "className:[\\w:/@\\[\\]\\-=&*>.]*"]
}
```
From da48984c16ac154bd56bdf01406a76bea6f52c3c Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Tue, 25 Aug 2026 21:21:25 +0000
Subject: [PATCH 5/5] feat: accept single-quoted UseClassy modifier values
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
class:hover='…' and className:@md='…' parse the same as double quotes,
including whitespace around = and arbitrary variants with inner =.
Co-authored-by: Jeremy Butler
---
README.md | 2 ++
src/core.ts | 20 +++++++++++++++-----
src/tests/core.test.ts | 27 +++++++++++++++++++++++++++
templates/useclassy-skill/SKILL.md | 4 ++--
4 files changed, 46 insertions(+), 7 deletions(-)
diff --git a/README.md b/README.md
index 6de76e2..9feec2d 100644
--- a/README.md
+++ b/README.md
@@ -62,6 +62,8 @@ If detection fails, follow the [manual setup](#vite) below.
Expressions with no string literals (`className:hover={hoverClasses}`) are left alone. Import types with `import 'vite-plugin-useclassy/react'` (or `ClassyProps`). React 18/19 is an optional peer, only needed for those helpers.
+Quoted modifier values may use `"` or `'`. Prefer `"` in docs and new code.
+
`className:@md`, `className:group-hover/item`, and arbitrary variants like `className:[&>*]` / `className:data-[state=open]` use the same attribute spelling as Vue. UseClassy rewrites them before the JSX/HTML parser runs (bracket-aware so `=` inside `[…]` stays part of the name). Put UseClassy before `@vitejs/plugin-react`. TypeScript and some linters may still flag the source the same way they already flag chained modifiers.
**Svelte.** Quoted modifiers transform; native directives do not. Put UseClassy before `@sveltejs/vite-plugin-svelte`.
diff --git a/src/core.ts b/src/core.ts
index ee10942..cefb7db 100644
--- a/src/core.ts
+++ b/src/core.ts
@@ -106,7 +106,8 @@ function modifierPrefixesForRegex(classModifierRegex: RegExp): ClassModifierPref
}
/**
- * Finds `class:mod="…"` / `className:mod="…"` with bracket-aware modifier names.
+ * Finds `class:mod="…"` / `class:mod='…'` / `className:mod="…"` with
+ * bracket-aware modifier names. Value quotes may be `"` or `'`.
*/
export function forEachQuotedClassModifier(
code: string,
@@ -155,19 +156,28 @@ export function forEachQuotedClassModifier(
while (i < code.length && /\s/.test(code[i]!))
i++
- if (code[i] !== '=' || code[i + 1] !== '"') {
+ if (code[i] !== '=') {
+ searchFrom = name.endIndex
+ continue
+ }
+ i++
+ while (i < code.length && /\s/.test(code[i]!))
+ i++
+
+ const quote = code[i]
+ if (quote !== '"' && quote !== '\'') {
searchFrom = name.endIndex
continue
}
- const valueStart = i + 2
+ const valueStart = i + 1
let j = valueStart
while (j < code.length) {
if (code[j] === '\\' && j + 1 < code.length) {
j += 2
continue
}
- if (code[j] === '"')
+ if (code[j] === quote)
break
j++
}
@@ -204,7 +214,7 @@ export const REACT_CLASS_MODIFIER_REGEX = new RegExp(
/**
* Svelte `class` regexes.
- * UseClassy modifiers use quoted values (`class:hover="..."`).
+ * UseClassy modifiers use quoted values (`class:hover="..."` or `class:hover='...'`).
* Native Svelte class directives (`class:active={cond}`, shorthand `class:active`)
* are left alone because they do not use a quoted string value.
* Unlike Vue, there is no `:class` binding lookahead.
diff --git a/src/tests/core.test.ts b/src/tests/core.test.ts
index 9bd9913..3af941e 100644
--- a/src/tests/core.test.ts
+++ b/src/tests/core.test.ts
@@ -437,6 +437,33 @@ describe('core module', () => {
expect(reactClasses.has('data-[state=open]:block')).toBeTruthy()
})
+ it('transforms single-quoted modifier values', () => {
+ const vueClasses = new Set()
+ const vueResult = transformClassModifiers(
+ `X
`,
+ vueClasses,
+ CLASS_MODIFIER_REGEX,
+ 'class',
+ )
+ expect(vueResult).toContain('hover:bg-red-500')
+ expect(vueResult).toContain('data-[state=open]:block')
+ expect(vueResult).not.toContain(`class:hover='`)
+ expect(vueClasses.has('hover:bg-red-500')).toBeTruthy()
+ expect(vueClasses.has('data-[state=open]:block')).toBeTruthy()
+
+ const reactClasses = new Set()
+ const reactResult = transformClassModifiers(
+ `X
`,
+ reactClasses,
+ REACT_CLASS_MODIFIER_REGEX,
+ 'className',
+ )
+ expect(reactResult).toContain('hover:text-lg')
+ expect(reactResult).toContain('@md:p-4')
+ expect(reactResult).not.toContain(`className:hover`)
+ expect(reactClasses.has('@md:p-4')).toBeTruthy()
+ })
+
it('transforms JSX expression arbitrary modifiers', () => {
const code
= `X
`
diff --git a/templates/useclassy-skill/SKILL.md b/templates/useclassy-skill/SKILL.md
index df0444f..dc27f08 100644
--- a/templates/useclassy-skill/SKILL.md
+++ b/templates/useclassy-skill/SKILL.md
@@ -33,8 +33,8 @@ Use UseClassy to separate Tailwind variants from base utilities:
Modifier names may contain letters, numbers, `_`, `-`, `:`, `/` (named groups such as `group-hover/item`), `@` (container queries such as `@md`), and arbitrary variants with `[…]` (`[&>*]`, `data-[state=open]`). UseClassy parses modifier names with bracket depth so `=` inside `[…]` is not the attribute separator. React uses the same modifier attributes as Vue; UseClassy rewrites them before JSX/HTML parse.
-- **Vue / Blade / Svelte / HTML:** modifier values must be double-quoted static class strings.
-- **React:** prefer double-quoted static strings. JSX expressions are also supported when string literals inside the expression should receive the variant prefix, e.g. `className:hover={on ? 'bg-blue-500' : 'bg-gray-200'}`.
+- **Vue / Blade / Svelte / HTML:** modifier values must be quoted static class strings (`"` or `'`).
+- **React:** prefer quoted static strings (`"` or `'`). JSX expressions are also supported when string literals inside the expression should receive the variant prefix, e.g. `className:hover={on ? 'bg-blue-500' : 'bg-gray-200'}`.
## Refactor existing code