Skip to content

Commit 5ef2bc5

Browse files
authored
(2/n) shadcn: css vars for tailwind v4 (#6487)
* feat(shadcn): add tailwind version detection * chore: changeset * feat(shadcn): css vars for tailwind v4 * fix(shadcn): handle color space * fix(shadcn): add oklch support * feat(shadcn): handle single quote * chore: add changeset
1 parent 8f6a64f commit 5ef2bc5

7 files changed

Lines changed: 565 additions & 5 deletions

File tree

.changeset/few-houses-impress.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"shadcn": minor
3+
---
4+
5+
add theme vars support

packages/shadcn/src/utils/add-components.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
workspaceConfigSchema,
1616
type Config,
1717
} from "@/src/utils/get-config"
18+
import { getProjectTailwindVersionFromConfig } from "@/src/utils/get-project-info"
1819
import { handleError } from "@/src/utils/handle-error"
1920
import { logger } from "@/src/utils/logger"
2021
import { spinner } from "@/src/utils/spinner"
@@ -74,12 +75,16 @@ async function addProjectComponents(
7475
}
7576
registrySpinner?.succeed()
7677

78+
const tailwindVersion = await getProjectTailwindVersionFromConfig(config)
79+
7780
await updateTailwindConfig(tree.tailwind?.config, config, {
7881
silent: options.silent,
82+
tailwindVersion,
7983
})
8084
await updateCssVars(tree.cssVars, config, {
8185
cleanupDefaultNextStyles: options.isNewProject,
8286
silent: options.silent,
87+
tailwindVersion,
8388
})
8489

8590
await updateDependencies(tree.dependencies, config, {
@@ -143,6 +148,10 @@ async function addWorkspaceComponents(
143148
? workspaceConfig.ui
144149
: config
145150

151+
const tailwindVersion = await getProjectTailwindVersionFromConfig(
152+
targetConfig
153+
)
154+
146155
const workspaceRoot = findCommonRoot(
147156
config.resolvedPaths.cwd,
148157
targetConfig.resolvedPaths.ui
@@ -155,6 +164,7 @@ async function addWorkspaceComponents(
155164
if (component.tailwind?.config) {
156165
await updateTailwindConfig(component.tailwind?.config, targetConfig, {
157166
silent: true,
167+
tailwindVersion,
158168
})
159169
filesUpdated.push(
160170
path.relative(workspaceRoot, targetConfig.resolvedPaths.tailwindConfig)
@@ -165,6 +175,7 @@ async function addWorkspaceComponents(
165175
if (component.cssVars) {
166176
await updateCssVars(component.cssVars, targetConfig, {
167177
silent: true,
178+
tailwindVersion,
168179
})
169180
filesUpdated.push(
170181
path.relative(workspaceRoot, targetConfig.resolvedPaths.tailwindCss)

packages/shadcn/src/utils/get-project-info.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,16 @@ import fs from "fs-extra"
1212
import { loadConfig } from "tsconfig-paths"
1313
import { z } from "zod"
1414

15+
export type TailwindVersion = "v3" | "v4" | null
16+
1517
type ProjectInfo = {
1618
framework: Framework
1719
isSrcDir: boolean
1820
isRSC: boolean
1921
isTsx: boolean
2022
tailwindConfigFile: string | null
2123
tailwindCssFile: string | null
22-
tailwindVersion: "v3" | "v4" | null
24+
tailwindVersion: TailwindVersion
2325
aliasPrefix: string | null
2426
}
2527

@@ -168,7 +170,11 @@ export async function getTailwindCssFile(cwd: string) {
168170
tailwindVersion === "v4" ? `@import "tailwindcss"` : "@tailwind base"
169171
for (const file of files) {
170172
const contents = await fs.readFile(path.resolve(cwd, file), "utf8")
171-
if (contents.includes(needle)) {
173+
if (
174+
contents.includes(`@import "tailwindcss"`) ||
175+
contents.includes(`@import 'tailwindcss'`) ||
176+
contents.includes(`@tailwind base`)
177+
) {
172178
return file
173179
}
174180
}
@@ -300,3 +306,19 @@ export async function getProjectConfig(
300306

301307
return await resolveConfigPaths(cwd, config)
302308
}
309+
310+
export async function getProjectTailwindVersionFromConfig(
311+
config: Config
312+
): Promise<TailwindVersion> {
313+
if (!config.resolvedPaths.cwd) {
314+
return "v3"
315+
}
316+
317+
const projectInfo = await getProjectInfo(config.resolvedPaths.cwd)
318+
319+
if (!projectInfo?.tailwindVersion) {
320+
return null
321+
}
322+
323+
return projectInfo.tailwindVersion
324+
}

packages/shadcn/src/utils/updaters/update-css-vars.ts

Lines changed: 140 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { promises as fs } from "fs"
22
import path from "path"
33
import { registryItemCssVarsSchema } from "@/src/registry/schema"
44
import { Config } from "@/src/utils/get-config"
5+
import { TailwindVersion, getProjectInfo } from "@/src/utils/get-project-info"
56
import { highlighter } from "@/src/utils/highlighter"
67
import { spinner } from "@/src/utils/spinner"
78
import postcss from "postcss"
@@ -16,6 +17,7 @@ export async function updateCssVars(
1617
options: {
1718
cleanupDefaultNextStyles?: boolean
1819
silent?: boolean
20+
tailwindVersion?: TailwindVersion
1921
}
2022
) {
2123
if (
@@ -29,6 +31,7 @@ export async function updateCssVars(
2931
options = {
3032
cleanupDefaultNextStyles: false,
3133
silent: false,
34+
tailwindVersion: "v3",
3235
...options,
3336
}
3437
const cssFilepath = config.resolvedPaths.tailwindCss
@@ -45,6 +48,7 @@ export async function updateCssVars(
4548
const raw = await fs.readFile(cssFilepath, "utf8")
4649
let output = await transformCssVars(raw, cssVars, config, {
4750
cleanupDefaultNextStyles: options.cleanupDefaultNextStyles,
51+
tailwindVersion: options.tailwindVersion,
4852
})
4953
await fs.writeFile(cssFilepath, output, "utf8")
5054
cssVarsSpinner.succeed()
@@ -56,21 +60,32 @@ export async function transformCssVars(
5660
config: Config,
5761
options: {
5862
cleanupDefaultNextStyles?: boolean
63+
tailwindVersion?: TailwindVersion
5964
} = {
6065
cleanupDefaultNextStyles: false,
66+
tailwindVersion: "v3",
6167
}
6268
) {
6369
options = {
6470
cleanupDefaultNextStyles: false,
71+
tailwindVersion: "v3",
6572
...options,
6673
}
6774

68-
const plugins = [updateCssVarsPlugin(cssVars)]
75+
let plugins = [updateCssVarsPlugin(cssVars)]
76+
77+
if (options.tailwindVersion === "v4") {
78+
plugins = [
79+
addCustomVariant({ params: "dark (&:is(.dark *))" }),
80+
updateCssVarsPluginV4(cssVars),
81+
updateThemePlugin(cssVars),
82+
]
83+
}
84+
6985
if (options.cleanupDefaultNextStyles) {
7086
plugins.push(cleanupDefaultNextStylesPlugin())
7187
}
7288

73-
// Only add the base layer plugin if we're using css variables.
7489
if (config.tailwind.cssVariables) {
7590
plugins.push(updateBaseLayerPlugin())
7691
}
@@ -298,3 +313,126 @@ function addOrUpdateVars(
298313
existingDecl ? existingDecl.replaceWith(newDecl) : ruleNode?.append(newDecl)
299314
})
300315
}
316+
317+
function updateCssVarsPluginV4(
318+
cssVars: z.infer<typeof registryItemCssVarsSchema>
319+
) {
320+
return {
321+
postcssPlugin: "update-css-vars-v4",
322+
Once(root: Root) {
323+
Object.entries(cssVars).forEach(([key, vars]) => {
324+
const selector = key === "light" ? ":root" : `.${key}`
325+
326+
let ruleNode = root.nodes?.find(
327+
(node): node is Rule =>
328+
node.type === "rule" && node.selector === selector
329+
)
330+
331+
if (!ruleNode) {
332+
ruleNode = postcss.rule({
333+
selector,
334+
nodes: [],
335+
raws: { semicolon: true, between: " ", before: "\n" },
336+
})
337+
root.append(ruleNode)
338+
}
339+
340+
Object.entries(vars).forEach(([key, value]) => {
341+
const prop = `--${key.replace(/^--/, "")}`
342+
343+
if (
344+
!value.startsWith("hsl") &&
345+
!value.startsWith("rgb") &&
346+
!value.startsWith("#") &&
347+
!value.startsWith("oklch")
348+
) {
349+
value = `hsl(${value})`
350+
}
351+
352+
const newDecl = postcss.decl({
353+
prop,
354+
value,
355+
raws: { semicolon: true },
356+
})
357+
const existingDecl = ruleNode?.nodes.find(
358+
(node): node is postcss.Declaration =>
359+
node.type === "decl" && node.prop === prop
360+
)
361+
existingDecl
362+
? existingDecl.replaceWith(newDecl)
363+
: ruleNode?.append(newDecl)
364+
})
365+
})
366+
},
367+
}
368+
}
369+
370+
function updateThemePlugin(cssVars: z.infer<typeof registryItemCssVarsSchema>) {
371+
return {
372+
postcssPlugin: "update-theme",
373+
Once(root: Root) {
374+
let themeNode = root.nodes.find(
375+
(node): node is AtRule =>
376+
node.type === "atrule" &&
377+
node.name === "theme" &&
378+
node.params === "inline"
379+
)
380+
381+
if (!themeNode) {
382+
themeNode = postcss.atRule({
383+
name: "theme",
384+
params: "inline",
385+
nodes: [],
386+
raws: { semicolon: true, between: " ", before: "\n" },
387+
})
388+
root.append(themeNode)
389+
}
390+
391+
// Find unique color names from light and dark.
392+
const colors = Array.from(
393+
new Set(
394+
Object.keys(cssVars).flatMap((key) =>
395+
Object.keys(cssVars[key as keyof typeof cssVars] || {})
396+
)
397+
)
398+
)
399+
400+
for (const color of colors) {
401+
const colorVar = postcss.decl({
402+
prop: `--color-${color.replace(/^--/, "")}`,
403+
value: `var(--${color})`,
404+
raws: { semicolon: true },
405+
})
406+
const existingDecl = themeNode?.nodes?.find(
407+
(node): node is postcss.Declaration =>
408+
node.type === "decl" && node.prop === colorVar.prop
409+
)
410+
if (!existingDecl) {
411+
themeNode?.append(colorVar)
412+
}
413+
}
414+
},
415+
}
416+
}
417+
418+
function addCustomVariant({ params }: { params: string }) {
419+
return {
420+
postcssPlugin: "add-custom-variant",
421+
Once(root: Root) {
422+
const customVariant = root.nodes.find(
423+
(node): node is AtRule =>
424+
node.type === "atrule" && node.name === "custom-variant"
425+
)
426+
if (!customVariant) {
427+
root.insertAfter(
428+
root.nodes[0],
429+
postcss.atRule({
430+
name: "custom-variant",
431+
params,
432+
raws: { semicolon: true, before: "\n" },
433+
})
434+
)
435+
}
436+
},
437+
}
438+
}

packages/shadcn/src/utils/updaters/update-tailwind-config.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { tmpdir } from "os"
33
import path from "path"
44
import { registryItemTailwindSchema } from "@/src/registry/schema"
55
import { Config } from "@/src/utils/get-config"
6+
import { TailwindVersion } from "@/src/utils/get-project-info"
67
import { highlighter } from "@/src/utils/highlighter"
78
import { spinner } from "@/src/utils/spinner"
89
import deepmerge from "deepmerge"
@@ -32,6 +33,7 @@ export async function updateTailwindConfig(
3233
config: Config,
3334
options: {
3435
silent?: boolean
36+
tailwindVersion?: TailwindVersion
3537
}
3638
) {
3739
if (!tailwindConfig) {
@@ -40,9 +42,15 @@ export async function updateTailwindConfig(
4042

4143
options = {
4244
silent: false,
45+
tailwindVersion: "v3",
4346
...options,
4447
}
4548

49+
// No tailwind config in v4.
50+
if (options.tailwindVersion === "v4") {
51+
return
52+
}
53+
4654
const tailwindFileRelativePath = path.relative(
4755
config.resolvedPaths.cwd,
4856
config.resolvedPaths.tailwindConfig
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
@import "tailwindcss";
1+
@import 'tailwindcss';

0 commit comments

Comments
 (0)