diff --git a/.changeset/cold-dancers-fix.md b/.changeset/cold-dancers-fix.md
new file mode 100644
index 0000000000..887e35cf92
--- /dev/null
+++ b/.changeset/cold-dancers-fix.md
@@ -0,0 +1,5 @@
+---
+"shadcn": patch
+---
+
+handle cn-\* class transformation in non-className contexts
diff --git a/packages/shadcn/src/styles/transform-style-map.test.ts b/packages/shadcn/src/styles/transform-style-map.test.ts
index 880e6bd46b..c72faa8b8b 100644
--- a/packages/shadcn/src/styles/transform-style-map.test.ts
+++ b/packages/shadcn/src/styles/transform-style-map.test.ts
@@ -1027,4 +1027,162 @@ function Menu({ className, ...props }: React.ComponentProps<"div">) {
"
`)
})
+
+ it("applies styles to cn-* classes in object properties (toastOptions pattern)", async () => {
+ const source = `import * as React from "react"
+import { Toaster as Sonner } from "sonner"
+
+const Toaster = ({ ...props }) => {
+ return (
+
+ )
+}
+`
+
+ const styleMap: StyleMap = {
+ "cn-toast": "rounded-2xl",
+ }
+
+ const result = await applyTransform(source, styleMap)
+
+ expect(result).toMatchInlineSnapshot(`
+ "import * as React from "react"
+ import { Toaster as Sonner } from "sonner"
+
+ const Toaster = ({ ...props }) => {
+ return (
+
+ )
+ }
+ "
+ `)
+ })
+
+ it("applies styles to cn-* classes in deeply nested object properties", async () => {
+ const source = `import * as React from "react"
+
+const config = {
+ options: {
+ classNames: {
+ wrapper: "cn-wrapper existing-class",
+ inner: "cn-inner",
+ },
+ },
+}
+`
+
+ const styleMap: StyleMap = {
+ "cn-wrapper": "flex flex-col",
+ "cn-inner": "p-4 rounded-lg",
+ }
+
+ const result = await applyTransform(source, styleMap)
+
+ expect(result).toMatchInlineSnapshot(`
+ "import * as React from "react"
+
+ const config = {
+ options: {
+ classNames: {
+ wrapper: "flex flex-col existing-class",
+ inner: "p-4 rounded-lg",
+ },
+ },
+ }
+ "
+ `)
+ })
+
+ it("removes cn-* classes from object properties when not in styleMap", async () => {
+ const source = `import * as React from "react"
+
+const config = {
+ classNames: {
+ item: "cn-unknown-class existing-class",
+ },
+}
+`
+
+ const styleMap: StyleMap = {}
+
+ const result = await applyTransform(source, styleMap)
+
+ expect(result).toMatchInlineSnapshot(`
+ "import * as React from "react"
+
+ const config = {
+ classNames: {
+ item: "existing-class",
+ },
+ }
+ "
+ `)
+ })
+
+ it("preserves allowlisted cn-* classes in object properties", async () => {
+ const source = `import * as React from "react"
+
+const config = {
+ classNames: {
+ target: "cn-menu-target",
+ },
+}
+`
+
+ const styleMap: StyleMap = {
+ "cn-menu-target": "z-50 origin-top",
+ }
+
+ const result = await applyTransform(source, styleMap)
+
+ expect(result).toMatchInlineSnapshot(`
+ "import * as React from "react"
+
+ const config = {
+ classNames: {
+ target: "cn-menu-target",
+ },
+ }
+ "
+ `)
+ })
+
+ it("handles multiple cn-* classes in object property values", async () => {
+ const source = `import * as React from "react"
+
+const options = {
+ toast: "cn-toast cn-toast-content extra-class",
+}
+`
+
+ const styleMap: StyleMap = {
+ "cn-toast": "rounded-lg",
+ "cn-toast-content": "p-4",
+ }
+
+ const result = await applyTransform(source, styleMap)
+
+ expect(result).toMatchInlineSnapshot(`
+ "import * as React from "react"
+
+ const options = {
+ toast: "rounded-lg p-4 extra-class",
+ }
+ "
+ `)
+ })
})
diff --git a/packages/shadcn/src/styles/transform-style-map.ts b/packages/shadcn/src/styles/transform-style-map.ts
index da43772f8a..914ea819cf 100644
--- a/packages/shadcn/src/styles/transform-style-map.ts
+++ b/packages/shadcn/src/styles/transform-style-map.ts
@@ -41,6 +41,7 @@ export const transformStyleMap: TransformerStyle = async ({
applyToCvaCalls(sourceFile, styleMap, matchedClasses)
applyToClassNameAttributes(sourceFile, styleMap, matchedClasses)
applyToMergePropsCalls(sourceFile, styleMap, matchedClasses)
+ applyToAllStringLiterals(sourceFile, styleMap)
return sourceFile
}
@@ -288,11 +289,6 @@ function extractCnClasses(str: string) {
return Array.from(matches, (match) => match[0])
}
-function extractCnClass(str: string) {
- const classes = extractCnClasses(str)
- return classes[0] ?? null
-}
-
function removeCnClasses(str: string) {
return str
.replace(/\bcn-[\w-]+\b/g, (match) => {
@@ -606,3 +602,69 @@ function applyClassesToCnCall(
cnCall.replaceWithText(`cn(${updatedArguments.join(", ")})`)
}
}
+
+function isInAlreadyProcessedContext(node: Node): boolean {
+ let current = node.getParent()
+
+ while (current) {
+ if (Node.isCallExpression(current)) {
+ const expression = current.getExpression()
+ if (Node.isIdentifier(expression)) {
+ const name = expression.getText()
+ if (name === "cn" || name === "cva") {
+ return true
+ }
+ }
+ }
+
+ if (Node.isJsxAttribute(current)) {
+ const attrName = current.getNameNode().getText()
+ if (attrName === "className") {
+ return true
+ }
+ }
+
+ current = current.getParent()
+ }
+
+ return false
+}
+
+function applyToAllStringLiterals(sourceFile: SourceFile, styleMap: StyleMap) {
+ sourceFile.forEachDescendant((node) => {
+ if (!isStringLiteralLike(node)) {
+ return
+ }
+
+ if (isInAlreadyProcessedContext(node)) {
+ return
+ }
+
+ const stringValue = node.getLiteralText()
+ const cnClasses = extractCnClasses(stringValue)
+
+ if (cnClasses.length === 0) {
+ return
+ }
+
+ // Skip allowlisted classes — they are handled at CLI install time.
+ const classesToInline = cnClasses.filter(
+ (cnClass) => !ALLOWLIST.has(cnClass)
+ )
+
+ const tailwindClassesToApply = classesToInline
+ .map((cnClass) => styleMap[cnClass])
+ .filter((classes): classes is string => Boolean(classes))
+
+ if (tailwindClassesToApply.length > 0) {
+ const mergedClasses = tailwindClassesToApply.join(" ")
+ const updated = removeCnClasses(mergeClasses(mergedClasses, stringValue))
+ node.setLiteralValue(updated)
+ } else {
+ const updated = removeCnClasses(stringValue)
+ if (updated !== stringValue) {
+ node.setLiteralValue(updated)
+ }
+ }
+ })
+}