diff --git a/docs/app/components/content/examples/dropdown-menu/DropdownMenuMatchContentWidthWithTriggerWidthExample.vue b/docs/app/components/content/examples/dropdown-menu/DropdownMenuMatchContentWidthWithTriggerWidthExample.vue
new file mode 100644
index 0000000000..be2b19d187
--- /dev/null
+++ b/docs/app/components/content/examples/dropdown-menu/DropdownMenuMatchContentWidthWithTriggerWidthExample.vue
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
diff --git a/docs/content/docs/2.components/dropdown-menu.md b/docs/content/docs/2.components/dropdown-menu.md
index 4de2512267..b4b4928167 100644
--- a/docs/content/docs/2.components/dropdown-menu.md
+++ b/docs/content/docs/2.components/dropdown-menu.md
@@ -382,6 +382,19 @@ defineShortcuts(extractShortcuts(items))
In this example, :kbd{value="meta"} :kbd{value="E"}, :kbd{value="meta"} :kbd{value="I"} and :kbd{value="meta"} :kbd{value="N"} would trigger the `select` function of the corresponding item.
::
+### Match content & trigger width
+
+You can set `--reka-dropdown-menu-trigger-width` css variable as content width to match the trigger button width.
+
+::component-example
+---
+
+collapse: true
+name: 'dropdown-menu-match-content-width-with-trigger-width-example'
+---
+
+::
+
## API
### Props
diff --git a/package.json b/package.json
index cd33665041..9fa61e2a9e 100644
--- a/package.json
+++ b/package.json
@@ -95,6 +95,8 @@
],
"scripts": {
"build": "nuxt-module-build build",
+ "build:repl:prepare": "node scripts/generate-repl-exports.mjs",
+ "build:repl": "pnpm build:repl:prepare && vite build -c vite.repl.config.ts",
"prepack": "pnpm build",
"dev": "nuxt dev playgrounds/nuxt --uiDev",
"dev:build": "nuxt build playgrounds/nuxt",
@@ -171,7 +173,8 @@
"release-it": "^19.0.4",
"vitest": "^3.2.4",
"vitest-environment-nuxt": "^1.0.1",
- "vue-tsc": "^3.0.6"
+ "vue-tsc": "^3.0.6",
+ "@vitejs/plugin-vue": "^5.1.4"
},
"peerDependencies": {
"@inertiajs/vue3": "^2.0.7",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 93af274b0d..ed8f52c5d3 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -192,6 +192,9 @@ importers:
'@release-it/conventional-changelog':
specifier: ^10.0.1
version: 10.0.1(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.2.0)(release-it@19.0.4(@types/node@24.0.7)(magicast@0.3.5))
+ '@vitejs/plugin-vue':
+ specifier: ^5.1.4
+ version: 5.2.4(vite@7.0.6(@types/node@24.0.7)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.0))(vue@3.5.21(typescript@5.8.3))
'@vue/test-utils':
specifier: ^2.4.6
version: 2.4.6
@@ -2781,6 +2784,13 @@ packages:
vite: ^5.0.0 || ^6.0.0 || ^7.0.0
vue: ^3.0.0
+ '@vitejs/plugin-vue@5.2.4':
+ resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==}
+ engines: {node: ^18.0.0 || >=20.0.0}
+ peerDependencies:
+ vite: ^5.0.0 || ^6.0.0
+ vue: ^3.2.25
+
'@vitejs/plugin-vue@6.0.1':
resolution: {integrity: sha512-+MaE752hU0wfPFJEUAIxqw18+20euHHdxVtMvbFcOEpjEyfqXH/5DCoTHiVJ0J29EhTJdoTkjEv5YBKU9dnoTw==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -10335,6 +10345,11 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@vitejs/plugin-vue@5.2.4(vite@7.0.6(@types/node@24.0.7)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.0))(vue@3.5.21(typescript@5.8.3))':
+ dependencies:
+ vite: 7.0.6(@types/node@24.0.7)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.0)
+ vue: 3.5.21(typescript@5.8.3)
+
'@vitejs/plugin-vue@6.0.1(vite@7.0.6(@types/node@24.0.7)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.0))(vue@3.5.21(typescript@5.8.3))':
dependencies:
'@rolldown/pluginutils': 1.0.0-beta.29
diff --git a/scripts/generate-repl-exports.mjs b/scripts/generate-repl-exports.mjs
new file mode 100644
index 0000000000..4eb3601031
--- /dev/null
+++ b/scripts/generate-repl-exports.mjs
@@ -0,0 +1,45 @@
+import { writeFileSync } from 'node:fs'
+import { fileURLToPath } from 'node:url'
+import { dirname, join, resolve } from 'pathe'
+import { pascalCase } from 'scule'
+import { glob } from 'tinyglobby'
+
+const rootDir = dirname(fileURLToPath(import.meta.url))
+const r = (...p) => resolve(rootDir, '..', ...p)
+
+const componentsDir = 'src/runtime/components'
+const outFile = r('src/repl/components.generated.ts')
+
+// Collect only top-level .vue files (no recursion) to exclude typography/prose etc.
+const files = (await glob('*.vue', { cwd: componentsDir, absolute: false })).sort()
+
+// Map base component name -> relative path (prefer shallower path if duplicate)
+const seen = new Map()
+for (const rel of files) {
+ const baseFile = rel.split('/').pop()
+ if (!baseFile) continue
+ const rawBase = baseFile.replace(/\.vue$/i, '')
+ // Convert to PascalCase in case filenames have dashes (e.g., page-hero.vue)
+ const base = pascalCase(rawBase)
+ // All are depth 1 in this mode; simply record if not already present
+ if (seen.has(base)) continue
+ seen.set(base, { rel, depth: 1 })
+}
+
+const names = Array.from(seen.keys()).sort()
+const lines = [
+ '// AUTO-GENERATED FILE. DO NOT EDIT.',
+ '// Generated by scripts/generate-repl-exports.mjs',
+ ''
+]
+for (const base of names) {
+ if (base.startsWith('_')) continue
+ const exportName = 'U' + base
+ const rel = seen.get(base).rel
+ const relPath = join('..', 'runtime', 'components', rel)
+ lines.push(`export { default as ${exportName} } from ${JSON.stringify(relPath)}`)
+}
+lines.push('')
+
+writeFileSync(outFile, lines.join('\n'), 'utf8')
+console.log(`Generated ${outFile} (top-level only) with ${names.length} component exports (scanned ${files.length} files).`)
diff --git a/src/repl/app.config.ts b/src/repl/app.config.ts
new file mode 100644
index 0000000000..f307d9eb66
--- /dev/null
+++ b/src/repl/app.config.ts
@@ -0,0 +1,15 @@
+// Minimal app.config used by Vue REPL builds
+// Mirrors a tiny subset of Nuxt appConfig consumed by useAppConfig and components
+const appConfig = {
+ ui: {
+ icons: {
+ dark: 'i-lucide-moon',
+ light: 'i-lucide-sun'
+ }
+ },
+ colorMode: {
+ preference: 'system'
+ }
+}
+
+export default appConfig
diff --git a/src/repl/components.generated.ts b/src/repl/components.generated.ts
new file mode 100644
index 0000000000..f494aad8ef
--- /dev/null
+++ b/src/repl/components.generated.ts
@@ -0,0 +1,2 @@
+// AUTO-GENERATED FILE. DO NOT EDIT.
+// Generated by scripts/generate-repl-exports.mjs
diff --git a/src/repl/image-component.ts b/src/repl/image-component.ts
new file mode 100644
index 0000000000..d4ffeda342
--- /dev/null
+++ b/src/repl/image-component.ts
@@ -0,0 +1,13 @@
+import { defineComponent, h } from 'vue'
+
+// Minimal image component stub for REPL builds
+export default defineComponent({
+ name: 'UiImageStub',
+ props: {
+ src: { type: String, default: '' },
+ alt: { type: String, default: '' }
+ },
+ setup(props, { attrs }) {
+ return () => h('img', { ...attrs, src: props.src, alt: props.alt })
+ }
+})
diff --git a/src/repl/index.ts b/src/repl/index.ts
new file mode 100644
index 0000000000..0d8c3001c7
--- /dev/null
+++ b/src/repl/index.ts
@@ -0,0 +1,12 @@
+// ESM entry for Vue REPL usage
+// Expose a curated set of Vue-compatible components and utilities.
+
+// Vue overrides live under runtime/vue/components
+// Generated top-level component exports
+export * from './components.generated'
+
+// Minimal composables often used in examples
+export { useAppConfig } from '../runtime/vue/composables/useAppConfig'
+
+// Types re-export for TS users in REPL-like setups (optional)
+export type * from '../runtime/types'
diff --git a/vite.repl.config.ts b/vite.repl.config.ts
new file mode 100644
index 0000000000..5ed49bbfad
--- /dev/null
+++ b/vite.repl.config.ts
@@ -0,0 +1,81 @@
+// This Vite config builds a single-file ESM bundle that can be loaded by the Vue REPL.
+// The REPL expects a plain ES module URL and already provides its own Vue runtime.
+// Our goal here is to:
+// - Compile our .vue Single File Components into JavaScript (via @vitejs/plugin-vue)
+// - Stub/alias Nuxt-only virtual imports so the code can run outside Nuxt
+// - Produce dist/repl-esm/index.js that you can host and reference in an import map
+import vue from '@vitejs/plugin-vue'
+import { fileURLToPath } from 'node:url'
+import { dirname, join } from 'pathe'
+import { defineConfig } from 'vite'
+
+const rootDir = dirname(fileURLToPath(import.meta.url))
+const r = (...p: string[]) => join(rootDir, ...p)
+
+export default defineConfig({
+ // Plugins run in order.
+ // - emptyTheme(): treats all `#build/ui/*` imports as an empty theme object.
+ // - vue(): compiles .vue files.
+ plugins: [emptyTheme(), vue()],
+ resolve: {
+ alias: [
+ // Nuxt auto-imports (`#imports`) don't exist in a plain Vite build.
+ // Point them to a small stub that provides minimal replacements used by our components.
+ { find: '#imports', replacement: r('src/runtime/vue/stubs.ts') },
+
+ // The library uses `useAppConfig()` which reads from `#build/app.config` in Nuxt.
+ // For REPL builds, we provide a tiny `app.config.ts` with the few values our examples need.
+ { find: '#build/app.config', replacement: r('src/repl/app.config.ts') },
+
+ // Some components import an image component via a virtual module.
+ // In the REPL, we alias it to a simple
wrapper.
+ { find: '#build/ui-image-component', replacement: r('src/repl/image-component.ts') }
+ ]
+ },
+ build: {
+ lib: {
+ // A small, curated entry that re-exports only the components and utilities
+ // we want to expose in the REPL (see src/repl/index.ts).
+ entry: r('src/repl/index.ts'),
+ // We only need ESM for the Vue REPL.
+ formats: ['es'],
+ // Create an easy-to-reference file name.
+ fileName: () => 'index.js',
+ // UMD name is unused for ESM, but Vite requires a name in lib mode.
+ name: 'NuxtUiRepl'
+ },
+ // Output folder for the REPL bundle.
+ outDir: r('dist/repl-esm'),
+ // Clean the folder before each build.
+ emptyOutDir: true,
+ rollupOptions: {
+ // IMPORTANT: Do not bundle Vue. The Vue REPL provides its own Vue runtime.
+ // Marking it external keeps our bundle lightweight and avoids version conflicts.
+ external: ['vue'],
+ output: {
+ globals: { vue: 'Vue' }
+ }
+ }
+ }
+})
+
+// Simplest possible theme handling for REPL builds:
+// We do NOT expose theme customization and we don't run Nuxt's code generator.
+// Map ALL `#build/ui/*` imports to a single virtual module returning an empty theme
+// object with the minimal shape expected by components that call tv(theme).
+// This eliminates the need for any files under src/repl/theme.
+export function emptyTheme() {
+ const VIRTUAL_ID = '\0nuxt-ui-empty-theme'
+ const THEME_CODE = 'export default { base: "", variants: {} }\n'
+ return {
+ name: 'nuxt-ui:repl-empty-theme',
+ resolveId(id: string) {
+ if (/^#build\/ui\//.test(id)) return VIRTUAL_ID
+ return null
+ },
+ load(id: string) {
+ if (id === VIRTUAL_ID) return THEME_CODE
+ return null
+ }
+ }
+}