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
| Prop | Type | Default | Description |
|---|---|---|---|
title | string | undefined | undefined | Heading. The mobile sheet requires it (rendered with the drag handle and close button). |
description | string | undefined | undefined | Subtext under the heading. |
closeIcon | string | undefined | "heroicons:x-mark" | Close-button icon (desktop UModal only). |
dismissible | boolean | true | Backdrop/close allowed; false emits close:prevent instead (desktop). |
scrollable | boolean | undefined | undefined | Pin the modal header/footer while the body scrolls. No local default — undefined falls through to UModal, whose own default is false. Desktop only. |
size | string | undefined | undefined | Maps 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
| Event | Payload | When |
|---|---|---|
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
| Slot | Props | Content |
|---|---|---|
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()— VueUseuseMediaQuery("(max-width: 1023px)")— the samelgboundary 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. sizeexists becauseUModalhas nosizeprop; the map above is applied as amax-w-*class on the modal content.title/descriptionare always shown on mobile (sheet header), so drop theheaderslot there;closeIcon,scrollableandsizelikewise only mean anything above 1024px.- Konsta (via
BottomSheet) and Nuxt UIUModalare the runtime deps; the konsta theme CSS must be loaded.