diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8d7e57da1da4..29bb63b49c55 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- Ensure `@plugin` resolves package JavaScript entries instead of browser CSS entries when using `@tailwindcss/vite` ([#19949](https://github.com/tailwindlabs/tailwindcss/pull/19949))
+- Fix relative `@import` and `@plugin` paths resolving from the wrong directory when using `@tailwindcss/vite` ([#19965](https://github.com/tailwindlabs/tailwindcss/pull/19965))
## [4.2.4] - 2026-04-21
diff --git a/integrations/vite/resolvers.test.ts b/integrations/vite/resolvers.test.ts
index 5ff5df5b174b..416737960eb1 100644
--- a/integrations/vite/resolvers.test.ts
+++ b/integrations/vite/resolvers.test.ts
@@ -513,6 +513,148 @@ test(
},
)
+test(
+ 'resolve relative CSS files correctly',
+ {
+ fs: {
+ 'package.json': json`
+ {
+ "type": "module",
+ "dependencies": {
+ "@tailwindcss/vite": "workspace:^",
+ "tailwindcss": "workspace:^"
+ },
+ "devDependencies": {
+ "vite": "^8"
+ }
+ }
+ `,
+ 'vite.config.ts': ts`
+ import tailwindcss from '@tailwindcss/vite'
+ import { defineConfig } from 'vite'
+
+ export default defineConfig({
+ build: { cssMinify: false },
+ plugins: [tailwindcss()],
+ })
+ `,
+ 'index.html': html`
+
+
+
+
+
+
+ `,
+ 'src/index.css': css`
+ @reference 'tailwindcss/theme';
+ @import 'tailwindcss/utilities';
+ @import './themes/glow.css';
+ `,
+ // References a file in the current folder, which names happens to match a
+ // file in the parent folder as well.
+ 'src/themes/glow.css': css`@import './entry.css';`,
+ 'src/themes/entry.css': css`
+ .do-include-me {
+ color: green;
+ }
+ `,
+
+ // Never rerefenced, so should not be included
+ 'src/entry.css': css`
+ .do-not-include-me {
+ color: red;
+ }
+ `,
+ },
+ },
+ async ({ exec, fs, expect }) => {
+ await exec('pnpm vite build')
+
+ expect((await fs.dumpFiles('./dist/**/*.css')).replace(/-([a-zA-Z0-9]*?)\.css/g, '-.css'))
+ .toMatchInlineSnapshot(`
+ "
+ --- ./dist/assets/index-.css ---
+ .do-include-me {
+ color: green;
+ }
+ "
+ `)
+ },
+)
+
+test(
+ 'resolve relative JS files correctly',
+ {
+ fs: {
+ 'package.json': json`
+ {
+ "type": "module",
+ "dependencies": {
+ "@tailwindcss/vite": "workspace:^",
+ "tailwindcss": "workspace:^"
+ },
+ "devDependencies": {
+ "vite": "^8"
+ }
+ }
+ `,
+ 'vite.config.ts': ts`
+ import tailwindcss from '@tailwindcss/vite'
+ import { defineConfig } from 'vite'
+
+ export default defineConfig({
+ build: { cssMinify: false },
+ plugins: [tailwindcss()],
+ })
+ `,
+ 'index.html': html`
+
+
+
+
+
+
+ `,
+ 'src/index.css': css`
+ @reference 'tailwindcss/theme';
+ @import 'tailwindcss/utilities';
+ @import './themes/glow.css';
+ `,
+ // References a file in the current folder, which names happens to match a
+ // file in the parent folder as well.
+ 'src/themes/glow.css': css`@plugin "./my-plugin.js";`,
+ 'src/themes/my-plugin.js': ts`
+ export default function ({ addBase }) {
+ addBase({ '.do-include-me': { color: 'green' } })
+ }
+ `,
+
+ // Never rerefenced, so should not be included
+ 'src/my-plugin.js': css`
+ export default function ({ addBase }) {
+ addBase({ '.do-not-include-me': { 'color': 'red' } })
+ }
+ `,
+ },
+ },
+ async ({ exec, fs, expect }) => {
+ await exec('pnpm vite build')
+
+ expect((await fs.dumpFiles('./dist/**/*.css')).replace(/-([a-zA-Z0-9]*?)\.css/g, '-.css'))
+ .toMatchInlineSnapshot(`
+ "
+ --- ./dist/assets/index-.css ---
+ @layer base {
+ .do-include-me {
+ color: green;
+ }
+ }
+ "
+ `)
+ },
+)
+
describe.each(['postcss', 'lightningcss'])('%s', (transformer) => {
test(
'resolves aliases in production build',
diff --git a/packages/@tailwindcss-vite/src/index.ts b/packages/@tailwindcss-vite/src/index.ts
index b0c25b5d7fe6..c6b2786f7bf1 100644
--- a/packages/@tailwindcss-vite/src/index.ts
+++ b/packages/@tailwindcss-vite/src/index.ts
@@ -33,6 +33,44 @@ export type PluginOptions = {
optimize?: boolean | { minify?: boolean }
}
+function createCustomResolver(
+ resolvers: ((id: string, importer: string) => Promise)[],
+ filter = (_path: string) => true,
+) {
+ return async (id: string, base: string) => {
+ // The resolver expects an `importer` file. We don't really know where the
+ // current `id` was imported from, but Vite will essentially do a
+ // `path.dirname(importer)` so it doesn't really matter.
+ //
+ // It does matter that this is a file, otherwise we would go up a directory,
+ // which means that we would be resolving files from a parent folder first,
+ // instead of the current folder we are in.
+ let importer = path.resolve(base, '__placeholder__.css')
+
+ for (let resolver of resolvers) {
+ let resolved = await resolver(id, importer)
+
+ // If we didn't resolve, we don't have to bail immediately, but we can try
+ // the next resolver
+ if (!resolved) continue
+
+ if (resolved === id) continue
+
+ // Looks like a relative file, let's resolve it to an absolute path
+ if (resolved[0] === '.') resolved = path.resolve(base, resolved)
+
+ // Must adhere to additional filters (e.g.: must be a .css file)
+ if (!filter(resolved)) continue
+
+ // If it's not an absolute path, then we don't really know how to read
+ // the file from disk.
+ if (!path.isAbsolute(resolved)) continue
+
+ return resolved
+ }
+ }
+}
+
export default function tailwindcss(opts: PluginOptions = {}): Plugin[] {
let servers: ViteDevServer[] = []
let config: ResolvedConfig | null = null
@@ -62,32 +100,20 @@ export default function tailwindcss(opts: PluginOptions = {}): Plugin[] {
let jsResolver = config!.createResolver(config!.resolve)
- customCssResolver = async (id: string, base: string) => {
- let resolved = await cssResolver(id, base, false, isSSR)
- if (!resolved) return
- if (resolved === id) return
- if (!path.isAbsolute(resolved)) return
- if (!resolved.endsWith('.css')) return
- return resolved
- }
- customJsResolver = async (id: string, base: string) => {
- // Resolve Vite aliases first so `@plugin "@/foo"` keeps working, but
- // let bare package specifiers fall through to Node-style resolution.
- let resolved = await jsResolver(id, base, true, isSSR)
- if (resolved && resolved !== id) {
- if (path.isAbsolute(resolved)) return resolved
- if (resolved[0] === '.') return path.resolve(base, resolved)
- }
-
- // Fall back to Vite's full resolver for features like tsconfigPaths,
- // but reject CSS results since plugins must resolve to executable code.
- resolved = await jsResolver(id, base, false, isSSR)
- if (!resolved) return
- if (resolved === id) return
- if (!path.isAbsolute(resolved)) return
- if (resolved.endsWith('.css')) return
- return resolved
- }
+ customCssResolver = createCustomResolver(
+ [
+ (id, importer) => cssResolver(id, importer, true, isSSR),
+ (id, importer) => cssResolver(id, importer, false, isSSR),
+ ],
+ (path) => path.endsWith('.css'),
+ )
+ customJsResolver = createCustomResolver(
+ [
+ (id, importer) => jsResolver(id, importer, true, isSSR),
+ (id, importer) => jsResolver(id, importer, false, isSSR),
+ ],
+ (path) => !path.endsWith('.css'),
+ )
} else {
type ResolveIdFn = (
environment: Environment,
@@ -129,32 +155,20 @@ export default function tailwindcss(opts: PluginOptions = {}): Plugin[] {
let jsResolver = createBackCompatIdResolver(env.config, env.config.resolve)
- customCssResolver = async (id: string, base: string) => {
- let resolved = await cssResolver(env, id, base, false)
- if (!resolved) return
- if (resolved === id) return
- if (!path.isAbsolute(resolved)) return
- if (!resolved.endsWith('.css')) return
- return resolved
- }
- customJsResolver = async (id: string, base: string) => {
- // Resolve Vite aliases first so `@plugin "@/foo"` keeps working, but
- // let bare package specifiers fall through to Node-style resolution.
- let resolved = await jsResolver(env, id, base, true)
- if (resolved && resolved !== id) {
- if (path.isAbsolute(resolved)) return resolved
- if (resolved[0] === '.') return path.resolve(base, resolved)
- }
-
- // Fall back to Vite's full resolver for features like tsconfigPaths,
- // but reject CSS results since plugins must resolve to executable code.
- resolved = await jsResolver(env, id, base, false)
- if (!resolved) return
- if (resolved === id) return
- if (!path.isAbsolute(resolved)) return
- if (resolved.endsWith('.css')) return
- return resolved
- }
+ customCssResolver = createCustomResolver(
+ [
+ (id, importer) => cssResolver(env, id, importer, true),
+ (id, importer) => cssResolver(env, id, importer, false),
+ ],
+ (path) => path.endsWith('.css'),
+ )
+ customJsResolver = createCustomResolver(
+ [
+ (id, importer) => jsResolver(env, id, importer, true),
+ (id, importer) => jsResolver(env, id, importer, false),
+ ],
+ (path) => !path.endsWith('.css'),
+ )
}
return new Root(