diff --git a/docs/content/docs/1.getting-started/2.installation/2.vue.md b/docs/content/docs/1.getting-started/2.installation/2.vue.md
index 3b725de070..13eae920ac 100644
--- a/docs/content/docs/1.getting-started/2.installation/2.vue.md
+++ b/docs/content/docs/1.getting-started/2.installation/2.vue.md
@@ -913,6 +913,59 @@ export default defineConfig({
Point `root` at your project root so the generated `.nuxt-ui` directory ends up in a `node_modules` that Tailwind scans.
::
+### `experimental.componentDetection` :badge{label="Soon" class="align-text-top"}
+
+Use the `experimental.componentDetection` option to enable automatic component detection for tree-shaking. This feature scans your source code to detect which components are actually used and only generates the necessary CSS for those components (including their dependencies). Without it, the Vite plugin generates the theme CSS for every component. Detection covers the Vite root, the packages listed in [`scanPackages`](#scanpackages) and any `dirs` from the [`components`](#components) option located outside the root.
+
+- Default: `false`{lang="ts-type"}
+- Type: `boolean | string[]`{lang="ts-type"}
+
+**Enable automatic detection:**
+
+```ts [vite.config.ts] {8-10}
+import { defineConfig } from 'vite'
+import vue from '@vitejs/plugin-vue'
+import ui from '@nuxt/ui/vite'
+
+export default defineConfig({
+ plugins: [
+ vue(),
+ ui({
+ experimental: {
+ componentDetection: true
+ }
+ })
+ ]
+})
+```
+
+**Include additional components for dynamic usage:**
+
+```ts [vite.config.ts] {8-10}
+import { defineConfig } from 'vite'
+import vue from '@vitejs/plugin-vue'
+import ui from '@nuxt/ui/vite'
+
+export default defineConfig({
+ plugins: [
+ vue(),
+ ui({
+ experimental: {
+ componentDetection: ['Modal', 'Dropdown', 'Popover']
+ }
+ })
+ ]
+})
+```
+
+::note
+When providing an array of component names, automatic detection is enabled and these components (along with their dependencies) are guaranteed to be included. This is useful for dynamic components like `` that can't be statically analyzed.
+::
+
+::warning
+Newly used components are picked up on the next dev-server start. If you add a component and its styles are missing, restart the dev server.
+::
+
## Continuous releases
Nuxt UI uses [pkg.pr.new](https://github.com/stackblitz-labs/pkg.pr.new) for continuous preview releases, providing developers with instant access to the latest features and bug fixes without waiting for official releases.
diff --git a/src/plugins/templates.ts b/src/plugins/templates.ts
index bbcf6871d7..51c8bebe52 100644
--- a/src/plugins/templates.ts
+++ b/src/plugins/templates.ts
@@ -3,13 +3,17 @@ import path from 'node:path'
import type { UnpluginOptions } from 'unplugin'
import type { NuxtUIOptions } from '../unplugin'
import { getTemplates } from '../templates'
+import { resolveExtraScanDirs } from '../utils/components'
/**
* This plugin is responsible for getting the generated virtual templates and
* making them available to the Vue build.
*/
-export default function TemplatePlugin(options: NuxtUIOptions, appConfig: Record) {
- const templates = getTemplates(options, appConfig.ui)
+export default function TemplatePlugin(options: NuxtUIOptions, appConfig: Record, componentDir?: string) {
+ // `root` is assigned in the `vite.config` hook (below), before the `ui.css`
+ // template's `getContents` runs — so `experimental.componentDetection` can scan the app.
+ const vue: { root?: string, dirs?: string[], componentDir?: string } = { componentDir }
+ const templates = getTemplates(options, appConfig.ui, undefined, undefined, vue)
const templateKeys = new Set(templates.map(t => `#build/${t.filename}`))
async function writeTemplates(root: string) {
@@ -61,7 +65,14 @@ export default function TemplatePlugin(options: NuxtUIOptions, appConfig: Record
// every theme class from the generated CSS.
// `options.root` lets setups like `electron-vite` override the location
// when `config.root` points to a sub-directory Tailwind doesn't scan.
- const alias = await writeTemplates(path.resolve(options.root || config.root || '.'))
+ vue.root = path.resolve(options.root || config.root || '.')
+ if (options.experimental?.componentDetection) {
+ // `scanPackages` packages resolve Nuxt UI components from `node_modules`
+ // and user component dirs can sit outside the root: detection has to
+ // scan both or their components lose their theme CSS.
+ vue.dirs = resolveExtraScanDirs(vue.root, options.scanPackages, options.components ? options.components.dirs : undefined)
+ }
+ const alias = await writeTemplates(vue.root)
return {
resolve: {
diff --git a/src/templates.ts b/src/templates.ts
index 7a7f372be6..a8334eaa84 100644
--- a/src/templates.ts
+++ b/src/templates.ts
@@ -12,7 +12,7 @@ import * as theme from './theme'
import * as themeProse from './theme/prose'
import * as themeContent from './theme/content'
-export function getTemplates(options: ModuleOptions, uiConfig: Record, nuxt?: Nuxt, resolve?: Resolver['resolve']) {
+export function getTemplates(options: ModuleOptions, uiConfig: Record, nuxt?: Nuxt, resolve?: Resolver['resolve'], vue?: { root?: string, dirs?: string[], componentDir?: string }) {
const templates: NuxtTemplate[] = []
let hasProse = false
@@ -116,37 +116,44 @@ export function getTemplates(options: ModuleOptions, uiConfig: Record layer.app)
- // Add layer sources
- for (const layer of layers) {
- sources.push(`@source "${layer}**/*";`)
- }
+ // Layer + inline sources are Nuxt-only; the Vue integration relies on the
+ // user's own Vite/Tailwind setup to scan their source.
+ const layers = nuxt ? getLayerDirectories(nuxt).map(layer => layer.app) : []
- // Add inline sources from Nuxt config (classes defined in config)
- const inlineConfigs = [
- nuxt.options.app?.rootAttrs?.class,
- nuxt.options.app?.head?.htmlAttrs?.class,
- nuxt.options.app?.head?.bodyAttrs?.class
- ]
+ if (nuxt) {
+ // Add layer sources
+ for (const layer of layers) {
+ sources.push(`@source "${layer}**/*";`)
+ }
- for (const value of inlineConfigs) {
- if (value && typeof value === 'string') {
- sources.push(`@source inline(${JSON.stringify(value)});`)
+ // Add inline sources from Nuxt config (classes defined in config)
+ const inlineConfigs = [
+ nuxt.options.app?.rootAttrs?.class,
+ nuxt.options.app?.head?.htmlAttrs?.class,
+ nuxt.options.app?.head?.bodyAttrs?.class
+ ]
+
+ for (const value of inlineConfigs) {
+ if (value && typeof value === 'string') {
+ sources.push(`@source inline(${JSON.stringify(value)});`)
+ }
}
}
- // Add theme sources (component detection or all)
- if (resolve && options.experimental?.componentDetection) {
+ // Add theme sources (component detection or all). Detection works for both
+ // integrations: Nuxt scans its layers and resolves the component dir via
+ // `resolve`; the Vue plugin scans the Vite root plus any extra dirs
+ // (`scanPackages` packages, component dirs outside the root).
+ const componentDir = resolve ? resolve('./runtime/components') : vue?.componentDir
+ const scanDirs = nuxt ? layers : [...(vue?.root ? [vue.root] : []), ...(vue?.dirs || [])]
+
+ if (options.experimental?.componentDetection && componentDir && scanDirs.length) {
const detectedComponents = await detectUsedComponents(
- layers,
+ scanDirs,
options.prefix!,
- resolve('./runtime/components'),
+ componentDir,
Array.isArray(options.experimental.componentDetection) ? options.experimental.componentDetection : undefined
)
diff --git a/src/unplugin.ts b/src/unplugin.ts
index dd3a36a112..8eee5b9524 100644
--- a/src/unplugin.ts
+++ b/src/unplugin.ts
@@ -1,6 +1,6 @@
import { fileURLToPath } from 'node:url'
-import { normalize } from 'pathe'
+import { join, normalize } from 'pathe'
import type { UnpluginOptions } from 'unplugin'
import { createUnplugin } from 'unplugin'
import type { Options as AutoImportOptions } from 'unplugin-auto-import/types'
@@ -36,7 +36,7 @@ type AppConfigUI = {
prefix?: string
} & TVConfig
-export interface NuxtUIOptions extends Omit {
+export interface NuxtUIOptions extends Omit {
/** Whether to generate declaration files for auto-imported components. */
dts?: boolean
ui?: AppConfigUI
@@ -115,7 +115,7 @@ export const NuxtUIPlugin = createUnplugin((_options
tailwind(),
IconsPlugin(options, appConfig),
PluginsPlugin(options),
- TemplatePlugin(options, appConfig),
+ TemplatePlugin(options, appConfig, join(runtimeDir, 'components')),
AppConfigPlugin(options, appConfig),
{
name: 'nuxt:ui:plugins-duplication-detection',
diff --git a/src/utils/components.ts b/src/utils/components.ts
index 7a36125532..9d83ac44a9 100644
--- a/src/utils/components.ts
+++ b/src/utils/components.ts
@@ -1,7 +1,9 @@
+import { existsSync } from 'node:fs'
import { readFile } from 'node:fs/promises'
-import { join } from 'pathe'
+import { dirname, join, normalize, resolve } from 'pathe'
import { globSync } from 'tinyglobby'
import { pascalCase } from 'scule'
+import { resolvePathSync } from 'mlly'
/**
* Build a dependency graph of components by scanning their source files
@@ -61,6 +63,41 @@ function resolveComponentDependencies(
return resolved
}
+/**
+ * Resolve additional directories component detection should scan besides the root:
+ * packages from `scanPackages` (they live in `node_modules`, which the root scan
+ * skips) and user component dirs pointing outside the root.
+ */
+export function resolveExtraScanDirs(root: string, scanPackages?: string[], componentDirs?: string | string[]): string[] {
+ const dirs = new Set()
+
+ for (const pkg of scanPackages || []) {
+ try {
+ const entry = normalize(resolvePathSync(pkg, { url: join(root, '_index.mjs') }))
+ // Slice at the last `node_modules//` so pnpm's `.pnpm` layout resolves
+ // to the package directory, not its entry file.
+ const marker = `node_modules/${pkg}`
+ const index = entry.lastIndexOf(`${marker}/`)
+ dirs.add(index === -1 ? dirname(entry) : entry.slice(0, index + marker.length))
+ } catch {
+ // Not resolvable from the root: nothing to scan.
+ }
+ }
+
+ const rootDir = normalize(root)
+ const userDirs = Array.isArray(componentDirs) ? componentDirs : componentDirs ? [componentDirs] : []
+ for (const dir of userDirs) {
+ // `dirs` entries can be globs: scan from the static prefix.
+ const resolved = resolve(root, dir.split(/[*{]/)[0]!)
+ // Dirs inside the root are already covered by the root scan.
+ if (resolved !== rootDir && !resolved.startsWith(`${rootDir}/`) && existsSync(resolved)) {
+ dirs.add(resolved)
+ }
+ }
+
+ return [...dirs]
+}
+
/**
* Detect components used in the project by scanning source files
*/
@@ -90,7 +127,9 @@ export async function detectUsedComponents(
for (const dir of dirs) {
const appFiles = globSync(['**/*.{vue,ts,js,tsx,jsx}'], {
cwd: dir,
- ignore: ['node_modules/**', '.nuxt/**', 'dist/**']
+ // `**/` prefixes so nested dirs are skipped too: the Vue integration
+ // scans the whole Vite root, not just Nuxt layer `app/` directories.
+ ignore: ['**/node_modules/**', '**/.nuxt/**', '**/dist/**']
})
for (const file of appFiles) {
diff --git a/test/utils/components.spec.ts b/test/utils/components.spec.ts
new file mode 100644
index 0000000000..bec85b9a56
--- /dev/null
+++ b/test/utils/components.spec.ts
@@ -0,0 +1,127 @@
+import { describe, it, expect, afterAll } from 'vitest'
+import { mkdtempSync, mkdirSync, writeFileSync, rmSync, realpathSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { detectUsedComponents, resolveExtraScanDirs } from '../../src/utils/components'
+
+const componentDir = join(process.cwd(), 'src/runtime/components')
+
+const fixtureDirs: string[] = []
+
+function fixtureUsing(markup: string) {
+ const dir = mkdtempSync(join(tmpdir(), 'nuxt-ui-cd-'))
+ fixtureDirs.push(dir)
+ writeFileSync(join(dir, 'App.vue'), `${markup}\n`)
+ return dir
+}
+
+// `realpathSync` because module resolution returns real paths and macOS puts
+// `tmpdir()` behind a `/private` symlink.
+function fixtureRoot() {
+ const dir = realpathSync(mkdtempSync(join(tmpdir(), 'nuxt-ui-cd-')))
+ fixtureDirs.push(dir)
+ return dir
+}
+
+function fixturePackage(root: string, name: string, contents = 'module.exports = {}\n') {
+ const dir = join(root, 'node_modules', name)
+ mkdirSync(dir, { recursive: true })
+ writeFileSync(join(dir, 'package.json'), JSON.stringify({ name, main: 'index.js' }))
+ writeFileSync(join(dir, 'index.js'), contents)
+ return dir
+}
+
+afterAll(() => {
+ for (const dir of fixtureDirs) {
+ rmSync(dir, { recursive: true, force: true })
+ }
+})
+
+describe('detectUsedComponents', () => {
+ it('detects used components and resolves their dependencies', async () => {
+ const detected = await detectUsedComponents([fixtureUsing('')], 'U', componentDir)
+
+ // Button + its dependency closure, nothing else.
+ expect(detected).toContain('Button')
+ expect(detected).toContain('Link')
+ expect(detected).not.toContain('Table')
+ expect(detected!.size).toBeLessThan(10)
+ })
+
+ it('detects lazy components', async () => {
+ const detected = await detectUsedComponents([fixtureUsing('')], 'U', componentDir)
+
+ expect(detected).toContain('Tooltip')
+ })
+
+ it('always includes components from includeComponents', async () => {
+ const detected = await detectUsedComponents([fixtureUsing('no nuxt ui here
')], 'U', componentDir, ['Modal'])
+
+ expect(detected).toContain('Modal')
+ expect(detected).toContain('Button')
+ })
+
+ it('skips files in nested node_modules and dist directories', async () => {
+ const dir = fixtureUsing('')
+ const nested = join(dir, 'packages', 'foo', 'node_modules', 'pkg')
+ const dist = join(dir, 'packages', 'foo', 'dist')
+ mkdirSync(nested, { recursive: true })
+ mkdirSync(dist, { recursive: true })
+ writeFileSync(join(nested, 'index.js'), 'export const UAlert = 1\n')
+ writeFileSync(join(dist, 'out.js'), 'export const UTable = 1\n')
+
+ const detected = await detectUsedComponents([dir], 'U', componentDir)
+
+ expect(detected).toContain('Button')
+ expect(detected).not.toContain('Alert')
+ expect(detected).not.toContain('Table')
+ })
+
+ it('returns undefined when no component is detected', async () => {
+ expect(await detectUsedComponents([fixtureUsing('no nuxt ui here
')], 'U', componentDir)).toBeUndefined()
+ })
+
+ it('detects usage inside a scanned package directory', async () => {
+ // The dir itself sits in `node_modules`: the ignore patterns apply to paths
+ // relative to it, so its files are still scanned.
+ const pkgDir = fixturePackage(fixtureRoot(), 'my-lib', 'export { UAlert } from "#components"\n')
+
+ expect(await detectUsedComponents([pkgDir], 'U', componentDir)).toContain('Alert')
+ })
+})
+
+describe('resolveExtraScanDirs', () => {
+ it('resolves scanPackages to their package directory', () => {
+ const root = fixtureRoot()
+ const pkgDir = fixturePackage(root, 'my-lib')
+
+ expect(resolveExtraScanDirs(root, ['my-lib'])).toEqual([pkgDir])
+ })
+
+ it('resolves scoped packages', () => {
+ const root = fixtureRoot()
+ const pkgDir = fixturePackage(root, '@scope/my-lib')
+
+ expect(resolveExtraScanDirs(root, ['@scope/my-lib'])).toEqual([pkgDir])
+ })
+
+ it('skips packages that cannot be resolved', () => {
+ expect(resolveExtraScanDirs(fixtureRoot(), ['missing-lib'])).toEqual([])
+ })
+
+ it('keeps component dirs outside the root and drops the ones inside', () => {
+ const root = fixtureRoot()
+ const outside = fixtureRoot()
+ mkdirSync(join(root, 'src', 'components'), { recursive: true })
+
+ expect(resolveExtraScanDirs(root, undefined, ['src/components', outside])).toEqual([outside])
+ })
+
+ it('resolves glob dirs from their static prefix', () => {
+ const root = fixtureRoot()
+ const outside = fixtureRoot()
+ mkdirSync(join(outside, 'components'), { recursive: true })
+
+ expect(resolveExtraScanDirs(root, undefined, [`${outside}/components/**`])).toEqual([join(outside, 'components')])
+ })
+})