diff --git a/docs/content/docs/2.components/modal.md b/docs/content/docs/2.components/modal.md
index 622b54fb84..b29f8d78d7 100644
--- a/docs/content/docs/2.components/modal.md
+++ b/docs/content/docs/2.components/modal.md
@@ -360,6 +360,36 @@ slots:
:placeholder{class="h-full"}
::
+### Force Mount
+
+Use the `portal` prop with an object to force the Modal content to render even when closed. This is useful for SSR when the modal should be visible on initial page load.
+
+::component-code
+---
+prettier: true
+ignore:
+ - title
+props:
+ portal:
+ to: false
+ forceMount: true
+ title: 'Modal with force mount'
+slots:
+ default: |
+
+
+
+ body: |
+
+
+---
+
+:u-button{label="Open" color="neutral" variant="subtle"}
+
+#body
+:placeholder{class="h-48"}
+::
+
## Examples
### Control open state
diff --git a/src/runtime/components/ContextMenu.vue b/src/runtime/components/ContextMenu.vue
index afba849fd5..2e2e064462 100644
--- a/src/runtime/components/ContextMenu.vue
+++ b/src/runtime/components/ContextMenu.vue
@@ -6,6 +6,7 @@ import theme from '#build/ui/context-menu'
import type { AvatarProps, IconProps, KbdProps, LinkProps } from '../types'
import type { ArrayOrNested, DynamicSlots, GetItemKeys, MergeTypes, NestedItem, EmitsToProps } from '../types/utils'
import type { ComponentConfig } from '../types/tv'
+import type { PortalProps } from '../composables/usePortal'
type ContextMenu = ComponentConfig
@@ -70,7 +71,7 @@ export interface ContextMenuProps = Arr
* Render the menu in a portal.
* @defaultValue true
*/
- portal?: boolean | string | HTMLElement
+ portal?: PortalProps
/**
* The key used to get the label from the item.
* @defaultValue 'label'
diff --git a/src/runtime/components/ContextMenuContent.vue b/src/runtime/components/ContextMenuContent.vue
index 34b8177786..948931c044 100644
--- a/src/runtime/components/ContextMenuContent.vue
+++ b/src/runtime/components/ContextMenuContent.vue
@@ -5,12 +5,13 @@ import type theme from '#build/ui/context-menu'
import type { AvatarProps, ContextMenuItem, ContextMenuSlots, IconProps, KbdProps } from '../types'
import type { ArrayOrNested, GetItemKeys } from '../types/utils'
import type { ComponentConfig } from '../types/tv'
+import type { PortalProps } from '../composables/usePortal'
type ContextMenu = ComponentConfig
interface ContextMenuContentProps> extends Omit {
items?: T
- portal?: boolean | string | HTMLElement
+ portal?: PortalProps
sub?: boolean
labelKey: GetItemKeys
descriptionKey: GetItemKeys
diff --git a/src/runtime/components/Drawer.vue b/src/runtime/components/Drawer.vue
index d1cba385cf..af862f3b1c 100644
--- a/src/runtime/components/Drawer.vue
+++ b/src/runtime/components/Drawer.vue
@@ -5,6 +5,7 @@ import type { AppConfig } from '@nuxt/schema'
import theme from '#build/ui/drawer'
import type { EmitsToProps } from '../types/utils'
import type { ComponentConfig } from '../types/tv'
+import type { PortalProps } from '../composables/usePortal'
type Drawer = ComponentConfig
@@ -37,7 +38,7 @@ export interface DrawerProps extends Pick
@@ -78,7 +79,7 @@ export interface DropdownMenuProps = A
* Render the menu in a portal.
* @defaultValue true
*/
- portal?: boolean | string | HTMLElement
+ portal?: PortalProps
/**
* The key used to get the label from the item.
* @defaultValue 'label'
diff --git a/src/runtime/components/DropdownMenuContent.vue b/src/runtime/components/DropdownMenuContent.vue
index e89d644400..ed0a67ebd1 100644
--- a/src/runtime/components/DropdownMenuContent.vue
+++ b/src/runtime/components/DropdownMenuContent.vue
@@ -6,12 +6,13 @@ import type theme from '#build/ui/dropdown-menu'
import type { KbdProps, AvatarProps, DropdownMenuItem, DropdownMenuSlots, IconProps } from '../types'
import type { ArrayOrNested, GetItemKeys, NestedItem, DynamicSlots, MergeTypes } from '../types/utils'
import type { ComponentConfig } from '../types/tv'
+import type { PortalProps } from '../composables/usePortal'
type DropdownMenu = ComponentConfig
interface DropdownMenuContentProps> extends Omit {
items?: T
- portal?: boolean | string | HTMLElement
+ portal?: PortalProps
sub?: boolean
labelKey: GetItemKeys
descriptionKey: GetItemKeys
diff --git a/src/runtime/components/InputMenu.vue b/src/runtime/components/InputMenu.vue
index e500db032d..9a0f395671 100644
--- a/src/runtime/components/InputMenu.vue
+++ b/src/runtime/components/InputMenu.vue
@@ -8,6 +8,7 @@ import type { ModelModifiers } from '../types/input'
import type { InputHTMLAttributes } from '../types/html'
import type { AcceptableValue, ArrayOrNested, GetItemKeys, GetItemValue, GetModelValue, GetModelValueEmits, NestedItem, EmitsToProps } from '../types/utils'
import type { ComponentConfig } from '../types/tv'
+import type { PortalProps } from '../composables/usePortal'
type InputMenu = ComponentConfig
@@ -104,7 +105,7 @@ export interface InputMenuProps = ArrayOr
* Render the menu in a portal.
* @defaultValue true
*/
- portal?: boolean | string | HTMLElement
+ portal?: PortalProps
/**
* Enable virtualization for large lists.
* Note: when enabled, all groups are flattened into a single list due to a limitation of Reka UI (https://github.com/unovue/reka-ui/issues/1885).
diff --git a/src/runtime/components/Modal.vue b/src/runtime/components/Modal.vue
index 827f467846..97be1ecd83 100644
--- a/src/runtime/components/Modal.vue
+++ b/src/runtime/components/Modal.vue
@@ -5,6 +5,7 @@ import theme from '#build/ui/modal'
import type { ButtonProps, IconProps, LinkPropsKeys } from '../types'
import type { EmitsToProps } from '../types/utils'
import type { ComponentConfig } from '../types/tv'
+import type { PortalProps } from '../composables/usePortal'
type Modal = ComponentConfig
@@ -37,7 +38,7 @@ export interface ModalProps extends DialogRootProps {
* Render the modal in a portal.
* @defaultValue true
*/
- portal?: boolean | string | HTMLElement
+ portal?: PortalProps
/**
* Display a close button to dismiss the modal.
* `{ size: 'md', color: 'neutral', variant: 'ghost' }`{lang="ts-type"}
diff --git a/src/runtime/components/Popover.vue b/src/runtime/components/Popover.vue
index fa3e6636c6..d5494a6c23 100644
--- a/src/runtime/components/Popover.vue
+++ b/src/runtime/components/Popover.vue
@@ -4,6 +4,7 @@ import type { AppConfig } from '@nuxt/schema'
import theme from '#build/ui/popover'
import type { EmitsToProps } from '../types/utils'
import type { ComponentConfig } from '../types/tv'
+import type { PortalProps } from '../composables/usePortal'
type Popover = ComponentConfig
type PopoverMode = 'click' | 'hover'
@@ -28,7 +29,7 @@ export interface PopoverProps extends Popov
* Render the popover in a portal.
* @defaultValue true
*/
- portal?: boolean | string | HTMLElement
+ portal?: PortalProps
/**
* The reference (or anchor) element that is being referred to for positioning.
*
diff --git a/src/runtime/components/Select.vue b/src/runtime/components/Select.vue
index 3d3bb53235..6b06e4f8a1 100644
--- a/src/runtime/components/Select.vue
+++ b/src/runtime/components/Select.vue
@@ -8,6 +8,7 @@ import type { ModelModifiers } from '../types/input'
import type { ButtonHTMLAttributes } from '../types/html'
import type { AcceptableValue, ArrayOrNested, GetItemKeys, GetItemValue, GetModelValue, GetModelValueEmits, NestedItem, EmitsToProps } from '../types/utils'
import type { ComponentConfig } from '../types/tv'
+import type { PortalProps } from '../composables/usePortal'
type Select = ComponentConfig
@@ -77,7 +78,7 @@ export interface SelectProps = ArrayOrNested
* Render the menu in a portal.
* @defaultValue true
*/
- portal?: boolean | string | HTMLElement
+ portal?: PortalProps
/**
* When `items` is an array of objects, select the field to use as the value.
* @defaultValue 'value'
diff --git a/src/runtime/components/SelectMenu.vue b/src/runtime/components/SelectMenu.vue
index dba7593f86..0e27fe7ab0 100644
--- a/src/runtime/components/SelectMenu.vue
+++ b/src/runtime/components/SelectMenu.vue
@@ -8,6 +8,7 @@ import type { ModelModifiers } from '../types/input'
import type { ButtonHTMLAttributes } from '../types/html'
import type { AcceptableValue, ArrayOrNested, GetItemKeys, GetItemValue, GetModelValue, GetModelValueEmits, NestedItem, EmitsToProps } from '../types/utils'
import type { ComponentConfig } from '../types/tv'
+import type { PortalProps } from '../composables/usePortal'
type SelectMenu = ComponentConfig
@@ -97,7 +98,7 @@ export interface SelectMenuProps = Array
* Render the menu in a portal.
* @defaultValue true
*/
- portal?: boolean | string | HTMLElement
+ portal?: PortalProps
/**
* Enable virtualization for large lists.
* Note: when enabled, all groups are flattened into a single list due to a limitation of Reka UI (https://github.com/unovue/reka-ui/issues/1885).
diff --git a/src/runtime/components/Slideover.vue b/src/runtime/components/Slideover.vue
index 0c12ea0e28..318e0b9e6a 100644
--- a/src/runtime/components/Slideover.vue
+++ b/src/runtime/components/Slideover.vue
@@ -5,6 +5,7 @@ import theme from '#build/ui/slideover'
import type { ButtonProps, IconProps, LinkPropsKeys } from '../types'
import type { EmitsToProps } from '../types/utils'
import type { ComponentConfig } from '../types/tv'
+import type { PortalProps } from '../composables/usePortal'
type Slideover = ComponentConfig
@@ -37,7 +38,7 @@ export interface SlideoverProps extends DialogRootProps {
* Render the slideover in a portal.
* @defaultValue true
*/
- portal?: boolean | string | HTMLElement
+ portal?: PortalProps
/**
* Display a close button to dismiss the slideover.
* `{ size: 'md', color: 'neutral', variant: 'ghost' }`{lang="ts-type"}
diff --git a/src/runtime/components/Toaster.vue b/src/runtime/components/Toaster.vue
index 66c56adcf1..68d3a05499 100644
--- a/src/runtime/components/Toaster.vue
+++ b/src/runtime/components/Toaster.vue
@@ -3,6 +3,7 @@ import type { ToastProviderProps } from 'reka-ui'
import type { AppConfig } from '@nuxt/schema'
import theme from '#build/ui/toaster'
import type { ComponentConfig } from '../types/tv'
+import type { PortalProps } from '../composables/usePortal'
type Toaster = ComponentConfig
@@ -26,7 +27,7 @@ export interface ToasterProps extends Omit
* Render the toaster in a portal.
* @defaultValue true
*/
- portal?: boolean | string | HTMLElement
+ portal?: PortalProps
/**
* Maximum number of toasts to display at once.
* @defaultValue 5
diff --git a/src/runtime/components/Tooltip.vue b/src/runtime/components/Tooltip.vue
index d10a0598f6..3323db6022 100644
--- a/src/runtime/components/Tooltip.vue
+++ b/src/runtime/components/Tooltip.vue
@@ -5,6 +5,7 @@ import theme from '#build/ui/tooltip'
import type { KbdProps } from '../types'
import type { EmitsToProps } from '../types/utils'
import type { ComponentConfig } from '../types/tv'
+import type { PortalProps } from '../composables/usePortal'
type Tooltip = ComponentConfig
@@ -27,7 +28,7 @@ export interface TooltipProps extends TooltipRootProps {
* Render the tooltip in a portal.
* @defaultValue true
*/
- portal?: boolean | string | HTMLElement
+ portal?: PortalProps
/**
* The reference (or anchor) element that is being referred to for positioning.
*
diff --git a/src/runtime/composables/usePortal.ts b/src/runtime/composables/usePortal.ts
index 9d39384ae9..8f2364119e 100644
--- a/src/runtime/composables/usePortal.ts
+++ b/src/runtime/composables/usePortal.ts
@@ -1,18 +1,61 @@
import { inject, computed } from 'vue'
import type { Ref, InjectionKey } from 'vue'
+import type { DialogPortalProps } from 'reka-ui'
export const portalTargetInjectionKey: InjectionKey[> = Symbol('nuxt-ui.portal-target')
-export function usePortal(portal: Ref) {
+export type PortalProps = boolean | string | HTMLElement | DialogPortalProps
+
+function isDialogPortalProps(p: unknown): p is DialogPortalProps {
+ return typeof p === 'object' && p !== null && ('to' in p || 'disabled' in p || 'defer' in p || 'forceMount' in p)
+}
+
+export function usePortal(portal: Ref) {
const globalPortal = inject(portalTargetInjectionKey, undefined)
- const value = computed(() => portal.value === true ? globalPortal?.value : portal.value)
+ const value = computed((): boolean | string | HTMLElement | undefined => {
+ const p = portal.value
+
+ if (p === true) {
+ return globalPortal?.value
+ }
+
+ if (isDialogPortalProps(p)) {
+ return p.to
+ }
+
+ return p
+ })
+
+ const disabled = computed(() => {
+ const p = portal.value
+
+ if (isDialogPortalProps(p) && p.disabled !== undefined) {
+ return p.disabled
+ }
+
+ return typeof value.value === 'boolean' ? !value.value : false
+ })
+
+ const to = computed(() => {
+ if (isDialogPortalProps(portal.value) && (typeof portal.value?.to === 'boolean' || typeof portal.value?.to === 'undefined')) {
+ return 'body'
+ }
+
+ if (typeof value.value === 'boolean') {
+ return 'body'
+ }
- const disabled = computed(() => typeof value.value === 'boolean' ? !value.value : false)
- const to = computed(() => typeof value.value === 'boolean' ? 'body' : value.value)
+ return value.value
+ })
+ const forceMount = computed(() => {
+ const p = portal.value
+ return isDialogPortalProps(p) && p.forceMount === true
+ })
return computed(() => ({
to: to.value,
- disabled: disabled.value
+ disabled: disabled.value,
+ forceMount: forceMount.value
}))
}
diff --git a/test/components/Modal.spec.ts b/test/components/Modal.spec.ts
index 1a770fc62d..5da979c58c 100644
--- a/test/components/Modal.spec.ts
+++ b/test/components/Modal.spec.ts
@@ -23,6 +23,7 @@ describe('Modal', () => {
['with closeIcon', { props: { ...props, closeIcon: 'i-lucide-trash' } }],
['with class', { props: { ...props, class: 'bg-elevated' } }],
['with ui', { props: { ...props, ui: { close: 'end-2' } } }],
+ ['with forceMount', { props: { ...props, portal: { forceMount: true, disabled: true } } }],
// Slots
['with default slot', { props, slots: { default: () => 'Default slot' } }],
['with content slot', { props, slots: { content: () => 'Content slot' } }],
diff --git a/test/components/__snapshots__/Modal-vue.spec.ts.snap b/test/components/__snapshots__/Modal-vue.spec.ts.snap
index 50a3f99f6c..53b2596cf7 100644
--- a/test/components/__snapshots__/Modal-vue.spec.ts.snap
+++ b/test/components/__snapshots__/Modal-vue.spec.ts.snap
@@ -253,6 +253,33 @@ exports[`Modal > renders with footer slot correctly 1`] = `
+"
+`;
+
+exports[`Modal > renders with forceMount correctly 1`] = `
+"
+
+
+
+
+
+
+
+
+
"
`;
diff --git a/test/components/__snapshots__/Modal.spec.ts.snap b/test/components/__snapshots__/Modal.spec.ts.snap
index 4704624553..aa4ad870d9 100644
--- a/test/components/__snapshots__/Modal.spec.ts.snap
+++ b/test/components/__snapshots__/Modal.spec.ts.snap
@@ -253,6 +253,33 @@ exports[`Modal > renders with footer slot correctly 1`] = `
+"
+`;
+
+exports[`Modal > renders with forceMount correctly 1`] = `
+"
+
+
+
+
+
+
+
+
+
"
`;
]