Skip to content

Dialog ​

A responsive dialog: at and above 1024px it renders Nuxt UI's UModal; below that it swaps to a konsta-based BottomSheet with a drag handle, so one component serves desktop and mobile.

vue
<script setup lang="ts">
import RyderDialog from "@opeolluwa/ryder/components/feedback/Dialog.vue"
</script>

<template>
  <RyderDialog v-model:open="open" title="Delete draft?" @after:leave="onClosed">
    <template #body>
      <p>The body slot works on both branches.</p>
    </template>
  </RyderDialog>
</template>

Examples ​

vue
<script setup lang="ts">
import { ref } from "vue";
import Dialog from "../../../src/components/feedback/Dialog.vue";

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

<template>
  <div class="space-y-3">
    <div class="flex flex-wrap items-center gap-3">
      <button
        type="button"
        class="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700"
        @click="open = true"
      >
        Open dialog
      </button>

      <span class="text-sm text-gray-500 dark:text-white/50">
        Last event: {{ status || "—" }}
      </span>
    </div>

    <Dialog
      v-model:open="open"
      title="Delete draft?"
      description="The draft is removed from your list."
      size="md"
      @after:leave="status = 'after:leave'"
    >
      <template #body>
        <p class="text-sm text-gray-500 dark:text-white/50">
          Above 1024px this is a Nuxt UI modal; below that the same component
          collapses into a konsta bottom sheet that draws its own header from
          <code class="font-mono">title</code> /
          <code class="font-mono">description</code>.
        </p>
      </template>

      <template #footer>
        <div class="flex w-full justify-end gap-2">
          <button
            type="button"
            class="rounded-lg px-3 py-2 text-sm font-medium text-gray-600 hover:bg-gray-100 dark:text-white/70 dark:hover:bg-white/10"
            @click="open = false"
          >
            Cancel
          </button>

          <button
            type="button"
            class="rounded-lg bg-primary-600 px-3 py-2 text-sm font-medium text-white hover:bg-primary-700"
            @click="status = 'confirmed'; open = false"
          >
            Confirm
          </button>
        </div>
      </template>
    </Dialog>
  </div>
</template>

With scrollable the header and footer stay pinned while the body scrolls (desktop only), and the header/footer slots show their close prop:

vue
<script setup lang="ts">
import { ref } from "vue";
import Dialog from "../../../src/components/feedback/Dialog.vue";

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

<template>
  <div class="space-y-3">
    <button
      type="button"
      class="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700"
      @click="open = true"
    >
      Open scrollable dialog
    </button>

    <Dialog
      v-model:open="open"
      scrollable
      size="xl"
      title="Terms of service"
      description="Scroll the body — the header and footer stay put."
    >
      <template #header="{ close }">
        <div class="flex w-full items-center justify-between gap-3">
          <span class="text-sm font-semibold">Custom header</span>

          <button
            type="button"
            aria-label="Close"
            class="rounded-lg px-2 py-1 text-sm text-gray-500 hover:bg-gray-100 dark:hover:bg-white/10"
            @click="close()"
          >
            Dismiss
          </button>
        </div>
      </template>

      <template #body>
        <div class="space-y-3">
          <p class="text-sm text-gray-500 dark:text-white/50">
            The <code class="font-mono">scrollable</code> prop keeps the header
            and footer pinned while the body scrolls — desktop only; the mobile
            sheet always scrolls its body.
          </p>

          <p class="text-sm text-gray-500 dark:text-white/50">
            1. These paragraphs exist only to make the body taller than the
            modal, so the scroll behaviour is visible in the demo.
          </p>

          <p class="text-sm text-gray-500 dark:text-white/50">
            2. On desktop every slot receives a <code class="font-mono">close</code>
            function, so a header or footer button can dismiss the overlay
            without any local state.
          </p>

          <p class="text-sm text-gray-500 dark:text-white/50">
            3. Below 1024px the sheet branch forwards the same slots without
            props, which is why the footer button below uses
            <code class="font-mono">close?.()</code>.
          </p>

          <p class="text-sm text-gray-500 dark:text-white/50">
            4. The <code class="font-mono">header</code> slot itself is
            desktop-only: the sheet builds its own header out of
            <code class="font-mono">title</code> and
            <code class="font-mono">description</code>.
          </p>

          <p class="text-sm text-gray-500 dark:text-white/50">
            5. Keep filling this pane until it scrolls — that is the whole point
            of the example.
          </p>
        </div>
      </template>

      <template #footer="{ close }">
        <div class="flex w-full justify-end gap-2">
          <button
            type="button"
            class="rounded-lg bg-primary-600 px-3 py-2 text-sm font-medium text-white hover:bg-primary-700"
            @click="close?.()"
          >
            Done
          </button>
        </div>
      </template>
    </Dialog>
  </div>
</template>

Props ​

PropTypeDefaultDescription
titlestring | undefinedundefinedHeading. The mobile sheet requires it (rendered with the drag handle and close button).
descriptionstring | undefinedundefinedSubtext under the heading.
closeIconstring | undefined"heroicons:x-mark"Close-button icon (desktop UModal only).
dismissiblebooleantrueBackdrop/close allowed; false emits close:prevent instead (desktop).
scrollableboolean | undefinedundefinedPin the modal header/footer while the body scrolls. No local default — undefined falls through to UModal, whose own default is false. Desktop only.
sizestring | undefinedundefinedMaps to a max width: sm→max-w-sm, md→max-w-md, lg→max-w-lg, xl→max-w-2xl. Other values add no width class.

Emits ​

EventPayloadWhen
cancel—Mobile sheet branch only: its close button, backdrop, or Cancel. The desktop modal emits leave instead.
leave—The overlay begins closing (both branches).
after:leave—The close transition finished (both branches; the sheet fires it 400 ms after leave).
close:prevent—A close was blocked because dismissible is false — desktop UModal only.

Slots ​

SlotPropsContent
header{ close }Desktop only — the sheet branch draws its own header from title/description. close closes the modal.
body{ close }Main content. close is bound on desktop only; the sheet branch forwards the slot without props.
footer{ close }Footer bar. Same desktop-only close caveat as body.

Notes ​

  • The branch is decided by useIsMobile() — VueUse useMediaQuery("(max-width: 1023px)") — the same lg boundary the shell chrome switches on.
  • The switch is deferred to onMounted (ready), so SSR and the first client render agree and there is no hydration mismatch.
  • size exists because UModal has no size prop; the map above is applied as a max-w-* class on the modal content.
  • title/description are always shown on mobile (sheet header), so drop the header slot there; closeIcon, scrollable and size likewise only mean anything above 1024px.
  • Konsta (via BottomSheet) and Nuxt UI UModal are the runtime deps; the konsta theme CSS must be loaded.