Modal

DialogGitHub
A dialog window that can be used to display a message or request user input.

Usage

Use a Button or any other component in the default slot of the Modal.

Then, use the #content slot to add the content displayed when the Modal is open.

<template>
  <PModal>
    <PButton label="Open" color="neutral" variant="subtle" />

    <template #content>
      <Placeholder class="h-48 m-4" />
    </template>
  </PModal>
</template>

You can also use the #header, #body and #footer slots to customize the Modal's content.

Title

Use the title prop to set the title of the Modal's header.

<template>
  <PModal title="Modal with title">
    <PButton label="Open" color="neutral" variant="subtle" />

    <template #body>
      <Placeholder class="h-48" />
    </template>
  </PModal>
</template>

Description

Use the description prop to set the description of the Modal's header.

<template>
  <PModal
    title="Modal with description"
    description="Lorem ipsum dolor sit amet, consectetur adipiscing elit."
  >
    <PButton label="Open" color="neutral" variant="subtle" />

    <template #body>
      <Placeholder class="h-48" />
    </template>
  </PModal>
</template>

Close

Use the close prop to customize or hide the close button (with false value) displayed in the Modal's header.

You can pass any property from the Button component to customize it.

<template>
  <PModal
    title="Modal with close button"
    :close="{
      color: 'primary',
      variant: 'outline',
      class: 'rounded-full'
    }"
  >
    <PButton label="Open" color="neutral" variant="subtle" />

    <template #body>
      <Placeholder class="h-48" />
    </template>
  </PModal>
</template>
The close button is not displayed if the #content slot is used as it's a part of the header.

Close Icon

Use the close-icon prop to customize the close button Icon. Defaults to i-lucide-x.

<template>
  <PModal title="Modal with close button" close-icon="i-lucide-arrow-right">
    <PButton label="Open" color="neutral" variant="subtle" />

    <template #body>
      <Placeholder class="h-48" />
    </template>
  </PModal>
</template>
You can customize this icon globally in your app.config.ts under ui.icons.close key.
You can customize this icon globally in your vite.config.ts under ui.icons.close key.

Transition

Use the transition prop to control whether the Modal is animated or not. Defaults to true.

<template>
  <PModal :transition="false" title="Modal without transition">
    <PButton label="Open" color="neutral" variant="subtle" />

    <template #body>
      <Placeholder class="h-48" />
    </template>
  </PModal>
</template>

Overlay

Use the overlay prop to control whether the Modal has an overlay or not. Defaults to true.

<template>
  <PModal :overlay="false" title="Modal without overlay">
    <PButton label="Open" color="neutral" variant="subtle" />

    <template #body>
      <Placeholder class="h-48" />
    </template>
  </PModal>
</template>

Use the modal prop to control whether the Modal blocks interaction with outside content. Defaults to true.

When modal is set to false, the overlay is automatically disabled and outside content becomes interactive.
<template>
  <PModal title="Modal interactive">
    <PButton label="Open" color="neutral" variant="subtle" />

    <template #body>
      <Placeholder class="h-48" />
    </template>
  </PModal>
</template>

Dismissible

Use the dismissible prop to control whether the Modal is dismissible when clicking outside of it or pressing escape. Defaults to true.

A close:prevent event will be emitted when the user tries to close it.
You can combine modal: false with dismissible: false to make the Modal's background interactive without closing it.
<template>
  <PModal :dismissible="false" modal title="Modal non-dismissible">
    <PButton label="Open" color="neutral" variant="subtle" />

    <template #body>
      <Placeholder class="h-48" />
    </template>
  </PModal>
</template>

Scrollable

Use the scrollable prop to make the Modal's content scrollable within the overlay.

As the overlay is needed for scrolling, modal: false is not compatible and overlay: false only removes the background.
<template>
  <PModal scrollable title="Modal scrollable">
    <PButton label="Open" color="neutral" variant="subtle" />

    <template #body>
      <Placeholder class="h-full" />
    </template>
  </PModal>
</template>
There's a known issue where clicking on the scrollbar may unintentionally close the dialog on some operating systems.

Fullscreen

Use the fullscreen prop to make the Modal fullscreen.

<template>
  <PModal fullscreen title="Modal fullscreen">
    <PButton label="Open" color="neutral" variant="subtle" />

    <template #body>
      <Placeholder class="h-full" />
    </template>
  </PModal>
</template>

Unmount

Use the unmount-on-hide prop to prevent the Modal's content from being unmounted when it is closed. Defaults to true.

<template>
  <PModal :unmount-on-hide="false" title="Modal">
    <PButton label="Open" color="neutral" variant="subtle" />

    <template #body>
      <Placeholder class="h-48" />
    </template>
  </PModal>
</template>
You can inspect the DOM to see the Modal's content being rendered even while it is closed.
When the portal prop is set to false, the content is also rendered on the server. This is useful to render an open Modal during SSR without a flash on page load, or to expose its content for SEO.

Examples

Control open state

You can control the open state by using the default-open prop or the v-model:open directive.

<script setup lang="ts">
const open = ref(false);

defineShortcuts({
  o: () => open.value = !open.value,
});
</script>

<template>
  <PModal v-model:open="open">
    <PButton
      label="Open"
      color="neutral"
      variant="subtle"
    />

    <template #content>
      <Placeholder class="m-4 h-48" />
    </template>
  </PModal>
</template>
<script setup lang="ts">
import { ref } from 'vue'

const open = ref(false);

defineShortcuts({
  o: () => open.value = !open.value,
});
</script>

<template>
  <PModal v-model:open="open">
    <PButton
      label="Open"
      color="neutral"
      variant="subtle"
    />

    <template #content>
      <Placeholder class="m-4 h-48" />
    </template>
  </PModal>
</template>
In this example, leveraging defineShortcuts, you can toggle the Modal by pressing O.
This lets you move the trigger outside of the Modal or remove it entirely.

Programmatic usage

You can use the useOverlay composable to open a Modal programmatically.

Make sure to wrap your app with the App component which uses the OverlayProvider component.

First, create a modal component that will be opened programmatically:

ModalExample.vue
<script setup lang="ts">
defineProps<{
  count: number
}>()

const emit = defineEmits<{ close: [boolean] }>()
</script>

<template>
  <PModal
    :close="{ onClick: () => emit('close', false) }"
    :title="`This modal was opened programmatically ${count} times`"
  >
    <template #footer>
      <div class="flex gap-2">
        <PButton color="neutral" label="Dismiss" @click="emit('close', false)" />
        <PButton label="Success" @click="emit('close', true)" />
      </div>
    </template>
  </PModal>
</template>
We are emitting a close event when the modal is closed or dismissed here. You can emit any data through the close event, and that data becomes the resolved value of open(). The event must be emitted for the promise to resolve.

Then, use it in your app:

<script setup lang="ts">
import { LazyModalExample } from '#components';

const count = ref(0);

const toast = useToast();
const overlay = useOverlay();

const modal = overlay.create(LazyModalExample);

async function open() {
  const instance = modal.open({
    count: count.value,
  });

  const shouldIncrement = await instance.result;

  if (shouldIncrement) {
    count.value++;

    toast.add({
      title: `Success: ${shouldIncrement}`,
      color: 'success',
      id: 'modal-success',
    });

    // Update the count
    modal.patch({
      count: count.value,
    });
    return;
  }

  toast.add({
    title: `Dismissed: ${shouldIncrement}`,
    color: 'error',
    id: 'modal-dismiss',
  });
}
</script>

<template>
  <PButton
    label="Open"
    color="neutral"
    variant="subtle"
    @click="open"
  />
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { LazyModalExample } from '#components';

const count = ref(0);

const toast = useToast();
const overlay = useOverlay();

const modal = overlay.create(LazyModalExample);

async function open() {
  const instance = modal.open({
    count: count.value,
  });

  const shouldIncrement = await instance.result;

  if (shouldIncrement) {
    count.value++;

    toast.add({
      title: `Success: ${shouldIncrement}`,
      color: 'success',
      id: 'modal-success',
    });

    // Update the count
    modal.patch({
      count: count.value,
    });
    return;
  }

  toast.add({
    title: `Dismissed: ${shouldIncrement}`,
    color: 'error',
    id: 'modal-dismiss',
  });
}
</script>

<template>
  <PButton
    label="Open"
    color="neutral"
    variant="subtle"
    @click="open"
  />
</template>
You can close the modal within the modal component by emitting emit('close').

Nested modals

You can nest modals within each other.

<script setup lang="ts">
const first = ref(false);
const second = ref(false);
</script>

<template>
  <PModal
    v-model:open="first"
    title="First modal"
    :ui="{ footer: 'justify-end' }"
  >
    <PButton
      color="neutral"
      variant="subtle"
      label="Open"
    />

    <template #footer>
      <PButton
        label="Close"
        color="neutral"
        variant="outline"
        @click="first = false"
      />

      <PModal
        v-model:open="second"
        title="Second modal"
        :ui="{ footer: 'justify-end' }"
      >
        <PButton
          label="Open second"
          color="neutral"
        />

        <template #footer>
          <PButton
            label="Close"
            color="neutral"
            variant="outline"
            @click="second = false"
          />
        </template>
      </PModal>
    </template>
  </PModal>
</template>
<script setup lang="ts">
import { ref } from 'vue'

const first = ref(false);
const second = ref(false);
</script>

<template>
  <PModal
    v-model:open="first"
    title="First modal"
    :ui="{ footer: 'justify-end' }"
  >
    <PButton
      color="neutral"
      variant="subtle"
      label="Open"
    />

    <template #footer>
      <PButton
        label="Close"
        color="neutral"
        variant="outline"
        @click="first = false"
      />

      <PModal
        v-model:open="second"
        title="Second modal"
        :ui="{ footer: 'justify-end' }"
      >
        <PButton
          label="Open second"
          color="neutral"
        />

        <template #footer>
          <PButton
            label="Close"
            color="neutral"
            variant="outline"
            @click="second = false"
          />
        </template>
      </PModal>
    </template>
  </PModal>
</template>

Use the #footer slot to add content after the Modal's body.

<script setup lang="ts">
const open = ref(false);
</script>

<template>
  <PModal
    v-model:open="open"
    title="Modal with footer"
    description="This is useful when you want a form in a Modal."
    :ui="{ footer: 'justify-end' }"
  >
    <PButton
      label="Open"
      color="neutral"
      variant="subtle"
    />

    <template #body>
      <Placeholder class="h-48" />
    </template>

    <template #footer="{ close }">
      <PButton
        label="Cancel"
        color="neutral"
        variant="outline"
        @click="close"
      />
      <PButton
        label="Submit"
        color="neutral"
      />
    </template>
  </PModal>
</template>
<script setup lang="ts">
import { ref } from 'vue'

const open = ref(false);
</script>

<template>
  <PModal
    v-model:open="open"
    title="Modal with footer"
    description="This is useful when you want a form in a Modal."
    :ui="{ footer: 'justify-end' }"
  >
    <PButton
      label="Open"
      color="neutral"
      variant="subtle"
    />

    <template #body>
      <Placeholder class="h-48" />
    </template>

    <template #footer="{ close }">
      <PButton
        label="Cancel"
        color="neutral"
        variant="outline"
        @click="close"
      />
      <PButton
        label="Submit"
        color="neutral"
      />
    </template>
  </PModal>
</template>

With command palette

You can use a CommandPalette component inside the Modal's content.

<script setup lang="ts">
const searchTerm = ref('');

const { data: users, status, execute } = await useLazyFetch('https://jsonplaceholder.typicode.com/users', {
  key: 'modal-command-palette-users',
  query: { q: searchTerm },
  transform: (data: Array<{ id: number; name: string; email: string }>) => {
    return data?.map((user) => ({ id: user.id, label: user.name, suffix: user.email, avatar: { src: `https://i.pravatar.cc/120?img=${user.id}`, loading: 'lazy' as const } })) || [];
  },
  immediate: false,
});

const groups = computed(() => [{
  id: 'users',
  label: searchTerm.value ? `Users matching “${searchTerm.value}”...` : 'Users',
  items: users.value || [],
  ignoreFilter: true,
}]);

function onOpen() {
  if (!users.value?.length) {
    execute();
  }
}
</script>

<template>
  <PModal @update:open="onOpen">
    <PButton
      label="Search users..."
      color="neutral"
      variant="subtle"
      icon="i-lucide-search"
    />

    <template #content>
      <PCommandPalette
        v-model:search-term="searchTerm"
        :loading="status === 'pending'"
        :groups="groups"
        placeholder="Search users..."
        class="h-80"
      />
    </template>
  </PModal>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'

const searchTerm = ref('');

const { data: users, status, execute } = await useLazyFetch('https://jsonplaceholder.typicode.com/users', {
  key: 'modal-command-palette-users',
  query: { q: searchTerm },
  transform: (data: Array<{ id: number; name: string; email: string }>) => {
    return data?.map((user) => ({ id: user.id, label: user.name, suffix: user.email, avatar: { src: `https://i.pravatar.cc/120?img=${user.id}`, loading: 'lazy' as const } })) || [];
  },
  immediate: false,
});

const groups = computed(() => [{
  id: 'users',
  label: searchTerm.value ? `Users matching “${searchTerm.value}”...` : 'Users',
  items: users.value || [],
  ignoreFilter: true,
}]);

function onOpen() {
  if (!users.value?.length) {
    execute();
  }
}
</script>

<template>
  <PModal @update:open="onOpen">
    <PButton
      label="Search users..."
      color="neutral"
      variant="subtle"
      icon="i-lucide-search"
    />

    <template #content>
      <PCommandPalette
        v-model:search-term="searchTerm"
        :loading="status === 'pending'"
        :groups="groups"
        placeholder="Search users..."
        class="h-80"
      />
    </template>
  </PModal>
</template>
This example uses useLazyFetch with immediate: false to only fetch data when the Modal opens.

API

Props

Prop Default Type
title
string
description
string
content
DialogContentProps & Partial<EmitsToProps<DialogContentImplEmits>>

The content of the modal.

overlay
true
boolean

Render an overlay behind the modal.

scrollable
false
boolean

When true, enables scrollable overlay mode where content scrolls within the overlay.

transition
true
boolean

Animate the modal when opening or closing.

fullscreen
false
boolean

When true, the modal will take up the full screen.

portal
true
string | false | true | HTMLElement

Render the modal in a portal.

close
true
boolean | Omit<ButtonProps, LinkPropsKeys>

Display a close button to dismiss the modal. { size: 'md', color: 'neutral', variant: 'ghost' }

closeIcon
appConfig.ui.icons.close
any

The icon displayed in the close button.

dismissible
true
boolean

When false, the modal will not close when clicking outside or pressing escape.

open
boolean

The controlled open state of the dialog. Can be binded as v-model:open.

defaultOpen
boolean

The open state of the dialog when it is initially rendered. Use when you do not need to control its open state.

modal
boolean

The modality of the dialog When set to true,
interaction with outside elements will be disabled and only dialog content will be visible to screen readers.

unmountOnHide
true
boolean

When set to false, the dialog content will not be unmounted when closed, but instead hidden with CSS.
Useful for SEO or when you want to improve performance by not remounting the component on every open.

ui
{ overlay?: SlotClass; content?: SlotClass; header?: SlotClass; wrapper?: SlotClass; body?: SlotClass; footer?: SlotClass; title?: SlotClass; description?: SlotClass; close?: SlotClass; }

Slots

Slot Type
default
{ open: boolean; }
content
{ close: () => void; }
header
{ close: () => void; }
title
{}
description
{}
actions
{}
close
{ ui: object; }
body
{ close: () => void; }
footer
{ close: () => void; }

Emits

Event Type
enter
[]
leave
[]
after:leave
[]
after:enter
[]
close:prevent
[]
update:open
[value: boolean]

Theme

app.config.ts
export default defineAppConfig({
  ui: {
    modal: {
      slots: {
        overlay: 'fixed inset-0',
        content: 'bg-background divide-y divide-border flex flex-col focus:outline-none',
        header: 'p-4 flex gap-1.5 min-h-$ui-header-height items-center sm:px-6',
        wrapper: '',
        body: 'flex-1 p-4 sm:p-6',
        footer: 'flex items-center gap-1.5 p-4 sm:px-6',
        title: 'color-text-highlighted font-600',
        description: 'mt-1 color-text-muted text-sm',
        close: 'absolute top-4 end-4'
      },
      variants: {
        transition: {
          true: {
            overlay: 'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
            content: 'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95'
          }
        },
        fullscreen: {
          true: {
            content: 'inset-0'
          },
          false: {
            content: 'w-[calc(100vw-2rem)] max-w-lg rounded-lg shadow-lg ring ring-border'
          }
        },
        overlay: {
          true: {
            overlay: 'bg-background-elevated/75'
          }
        },
        scrollable: {
          true: {
            overlay: 'overflow-y-auto',
            content: 'relative'
          },
          false: {
            content: 'fixed',
            body: 'overflow-y-auto'
          }
        }
      },
      compoundVariants: [
        {
          scrollable: true,
          fullscreen: false,
          class: {
            overlay: 'grid place-items-center p-4 sm:py-8'
          }
        },
        {
          scrollable: false,
          fullscreen: false,
          class: {
            content: 'top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 max-h-[calc(100dvh-2rem)] sm:max-h-[calc(100dvh-4rem)] overflow-hidden'
          }
        }
      ]
    }
  }
})
vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import ui from 'pohon-ui/vite'

export default defineConfig({
  plugins: [
    vue(),
    ui({
      ui: {
        modal: {
          slots: {
            overlay: 'fixed inset-0',
            content: 'bg-background divide-y divide-border flex flex-col focus:outline-none',
            header: 'p-4 flex gap-1.5 min-h-$ui-header-height items-center sm:px-6',
            wrapper: '',
            body: 'flex-1 p-4 sm:p-6',
            footer: 'flex items-center gap-1.5 p-4 sm:px-6',
            title: 'color-text-highlighted font-600',
            description: 'mt-1 color-text-muted text-sm',
            close: 'absolute top-4 end-4'
          },
          variants: {
            transition: {
              true: {
                overlay: 'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
                content: 'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95'
              }
            },
            fullscreen: {
              true: {
                content: 'inset-0'
              },
              false: {
                content: 'w-[calc(100vw-2rem)] max-w-lg rounded-lg shadow-lg ring ring-border'
              }
            },
            overlay: {
              true: {
                overlay: 'bg-background-elevated/75'
              }
            },
            scrollable: {
              true: {
                overlay: 'overflow-y-auto',
                content: 'relative'
              },
              false: {
                content: 'fixed',
                body: 'overflow-y-auto'
              }
            }
          },
          compoundVariants: [
            {
              scrollable: true,
              fullscreen: false,
              class: {
                overlay: 'grid place-items-center p-4 sm:py-8'
              }
            },
            {
              scrollable: false,
              fullscreen: false,
              class: {
                content: 'top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 max-h-[calc(100dvh-2rem)] sm:max-h-[calc(100dvh-4rem)] overflow-hidden'
              }
            }
          ]
        }
      }
    })
  ]
})

Changelog

No recent changes