Skip to content

Modal Component ​

The Modal component is a versatile dialog component built with Vue 3 and Headless UI. It provides a customizable modal interface perfect for forms, alerts, or any content that needs to appear in a dialog.

Installation ​

To use the Modal component you need to install the @headlessui/vue package.

bash
yarn add @robuust-digital/vue-components @headlessui/vue

Import the modal from dialog package ​

js
import { Modal } from '@robuust-digital/vue-components/dialogs';

Import CSS ​

css
@import "@robuust-digital/vue-components/dialogs/css";

Basic Usage ​

Import and use Modal in your Vue components:

vue
<template>
  <Modal
    id="example-modal"
    title="Example Modal"
    :show="isOpen"
    @modal:close="isOpen = false"
  >
    <p>Modal content goes here</p>
  </Modal>
</template>

<script setup>
import { ref } from 'vue';
import { Modal } from '@robuust-digital/vue-components/dialogs';

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

Props ​

PropTypeDefaultDescription
asString'div'Element type to render (div or form)
idStringRequiredUnique identifier for the modal
titleStringRequiredTitle displayed in the modal header
showCloseBooleanfalseShows close button in header when true
spinningBooleanfalseShows loading state in submit button
submitLabelString'Confirm'Text for the submit button
cancelLabelString'Cancel'Text for the cancel button
panelClassString'sm:max-w-lg'CSS class for modal panel width
resetHeaderBooleanfalseResets / hides default header and contents
resetFooterBooleanfalseResets / hides default footer and contents

Events ​

EventArgumentsDescription
modal:open-Emitted when modal starts opening
modal:opened-Emitted when open Transition finishes
modal:close-Emitted when modal close is requested
modal:saveeventEmitted when form is submitted (if as="form")
modal:closed-Emitted when close Transition finishes

Slots ​

Root component slot props ​

When as is a component, the props from its default slot are passed through to every Modal slot: default, panel, header, title, close, and footer. This also applies when resetHeader or resetFooter is enabled. Existing Modal slot props take precedence if names overlap; for example, loading still reflects Modal's spinning prop.

For example, an application's Inertia Form can expose errors in the content and processing in the footer:

vue
<script setup>
import { ref } from 'vue';
import { Form } from '@inertiajs/vue3';
import { ButtonBase } from '@robuust-digital/vue-components/core';
import { Modal } from '@robuust-digital/vue-components/dialogs';

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

<template>
  <ButtonBase label="Create user" @click="isOpen = true" />
  <Modal
    id="create-user"
    :as="Form"
    title="Create user"
    action="/users"
    method="post"
    :show="isOpen"
    @modal:close="isOpen = false"
  >
    <template #default="{ errors }">
      <input name="name" aria-label="Name" />
      <p v-if="errors.name">{{ errors.name }}</p>
    </template>
    <template #footer="{ processing }">
      <ButtonBase
        type="submit"
        label="Save"
        :spinning="processing"
        :disabled="processing"
      />
    </template>
  </Modal>
</template>

RVC forwards these props without interpreting them or depending on Inertia. The built-in footer continues to use spinning; use a custom footer to connect the root component's state to its buttons.

Default Slot ​

The main content area of the modal.

vue
<template>
  <Modal id="default-slot-modal" title="Example">
    <p>Main content here</p>
  </Modal>
</template>

Header Slot ​

Customize the header section:

vue
<template>
  <Modal id="header-slot-modal" title="Example">
    <template #header="{ title, showClose }">
      <div class="custom-header">
        <h2>{{ title }}</h2>
        <button v-if="showClose" @click="$emit('modal:close')">Close</button>
      </div>
    </template>
  </Modal>
</template>

Title slot ​

Customize the title:

vue
<template>
  <Modal id="title-slot-modal" title="Example">
    <template #title="{ title, id, dialogTitle }">
      <div>
        <Component :is="dialogTitle" :id="id">
          {{ title }}
        </Component>
        <p class="text-sm font-light">
          Little custom subtitle
        </p>
      </div>
    </template>
  </Modal>
</template>

Close slot ​

Customize the close button:

vue
<template>
  <Modal id="close-modal-slot" title="Example">
    <template #close="{ icon, emitClose }">
      <button @click="emitClose" type="button">
        <Component :is="icon" aria-hidden="true" />
      </button>
    </template>
  </Modal>
</template>

Customize the footer actions:

vue
<template>
  <Modal id="footer-slot-modal" title="Example">
    <template #footer="{ loading }">
      <ButtonBase label="Custom Cancel" @click="$emit('modal:close')" />
      <ButtonBase 
        type="submit"
        label="Custom Confirm"
        :spinning="loading"
      />
    </template>
  </Modal>
</template>

Panel Slot ​

Use panel when you want to replace the full inner modal layout (header/content/footer) with your own structure.

Available slot props:

PropTypeDescription
titleStringCurrent modal title
loadingBooleanLoading state (spinning prop)
isFormBooleanWhether the modal renders as a form
titleIdStringAccessible title id
descriptionIdStringAccessible description id
contentRefRef<HTMLElement | null>Content reference used for focus handling
dialogTitleComponentHeadless UI DialogTitle component
emitClose() => voidEmits modal:close
emitSave(event?: Event) => voidEmits modal:save
vue
<template>
  <Modal id="panel-slot-modal" title="Example">
    <template #panel="{
      title,
      titleId,
      descriptionId,
      dialogTitle,
      contentRef,
      loading,
      emitClose,
      emitSave,
    }">
      <div class="custom-panel-layout">
        <Component :is="dialogTitle" :id="titleId">
          {{ title }}
        </Component>

        <div :id="descriptionId" :ref="contentRef">
          <p>Completely custom layout inside the modal panel.</p>
        </div>

        <div class="actions">
          <ButtonBase color="light" label="Cancel" @click="emitClose" />
          <ButtonBase :spinning="loading" label="Save" @click="emitSave" />
        </div>
      </div>
    </template>
  </Modal>
</template>

Examples ​

Basic Form Modal ​

vue
<template>
  <ButtonBase label="Open Modal" @click="isOpen = true" />
  
  <Modal
    id="create-user-modal"
    as="form"
    title="Create User"
    :show="isOpen"
    @modal:close="isOpen = false"
    @modal:save="handleSave"
  >
    <div class="space-y-4">
      <input v-model="formData.name" type="text" placeholder="Name" required />
      <input v-model="formData.email" type="email" placeholder="Email" required />
    </div>
  </Modal>
</template>

<script setup>
import { ref } from 'vue';

const isOpen = ref(false);
const formData = ref({
  name: '',
  email: '',
});

const handleSave = (e) => {
  console.log('Form submitted:', formData.value);
  isOpen.value = false;
};
</script>

Custom Width Modal ​

vue
<template>
  <Modal
    id="wide-modal"
    title="Wide Modal"
    panel-class="max-w-2xl"
    :show="isOpen"
    @modal:close="isOpen = false"
  >
    <p>This modal uses a custom width class.</p>
  </Modal>
</template>

Loading State ​

vue
<template>
  <Modal
    id="loading-modal"
    title="Saving..."
    :show="isOpen"
    :spinning="isSaving"
    @modal:close="isOpen = false"
    @modal:save="handleSave"
  >
    <p>The footer submit button will show a spinner when spinning is true.</p>
  </Modal>
</template>

Show Close Icon Modal ​

vue
<template>
  <Modal
    id="close-icon-modal"
    title="Modal With Close Icon"
    show-close
    :show="isOpen"
    @modal:close="isOpen = false"
  >
    <p>This modal has a close icon in the header. Clicking the icon will close the modal.</p>
  </Modal>
</template>

CSS Customization ⚡️ ​

To customize the modal styles global

css
:root {
  /* Available variables */
  --rvc-modal-backdrop-bg-color: var(--rvc-dialog-backdrop-bg-color);
  --rvc-modal-border-color: var(--rvc-base-border-color);
  --rvc-modal-border-width: var(--rvc-base-border-width);
  --rvc-modal-border-style: var(--rvc-base-border-style);
  --rvc-modal-border-radius: var(--rvc-base-border-radius);
  --rvc-modal-padding-x: var(--rvc-dialog-padding-x);
  --rvc-modal-padding-y: var(--rvc-dialog-padding-y);
  --rvc-modal-header-bg-color: var(--rvc-dialog-header-bg-color);
  --rvc-modal-title-font-size: var(--rvc-dialog-title-font-size);
  --rvc-modal-title-font-weight: var(--rvc-dialog-title-font-weight);
  --rvc-modal-title-font-family: var(--rvc-dialog-title-font-family);
  --rvc-modal-title-color: var(--rvc-dialog-title-color);
  --rvc-modal-close-size: var(--rvc-dialog-close-size);
  --rvc-modal-close-color: var(--rvc-dialog-close-color);
  --rvc-modal-close-color-hover: var(--rvc-dialog-close-color-hover);
  --rvc-modal-content-bg-color: var(--rvc-dialog-content-bg-color);
  --rvc-modal-footer-bg-color: var(--rvc-dialog-footer-bg-color);
  --rvc-modal-footer-gap: var(--rvc-dialog-footer-gap);
  --rvc-modal-box-shadow: var(--box-shadow-xl);
  --rvc-modal-margin-y: 0;
}