Dialog

A modal dialog that interrupts the current task.

<script setup lang="ts">
  import { Avatar, Button, Dialog, Inline, Input, Stack, Text } from '@hina-ui/vue'
</script>

<template>
  <Dialog title="Edit profile" description="Changes sync to your homepage right away.">
    <Button variant="outline" tone="neutral">Edit profile</Button>
    <template #content>
      <Stack gap="sm">
        <Inline gap="sm">
          <Avatar size="lg" src="/avatars/glass.webp" alt="Shion Hoshimi" />
          <Button size="sm" variant="soft" tone="neutral">Change picture</Button>
        </Inline>
        <Stack gap="xs">
          <Text size="sm">Display name</Text>
          <Input model-value="Shion Hoshimi" />
        </Stack>
        <Stack gap="xs">
          <Text size="sm">Bio</Text>
          <Input
            model-value="A travelling merchant and a girl who calls herself a harvest deity."
          />
        </Stack>
      </Stack>
    </template>
    <template #footer="{ close }">
      <Button variant="soft" tone="neutral" @click="close">Cancel</Button>
      <Button @click="close">Save</Button>
    </template>
  </Dialog>
</template>
vue

Usage

import { Dialog } from '@hina-ui/vue'
ts

title is required and description is the line under it. The default slot is the trigger, the content slot is the body, and the footer slot holds the actions. The content, footer and body slots receive a close method; body replaces the entire interior layout.

<script setup lang="ts">
  import { Button, Dialog, Text } from '@hina-ui/vue'
</script>

<template>
  <Dialog title="Publish this article" description="Everyone can see it once it is published.">
    <Button variant="outline" tone="neutral">Publish</Button>
    <template #content>
      <Text>The article appears on your homepage and in the timelines of your subscribers.</Text>
    </template>
    <template #footer="{ close }">
      <Button variant="soft" tone="neutral" @click="close">Not yet</Button>
      <Button @click="close">Publish</Button>
    </template>
  </Dialog>
</template>
vue

The close button, the scrim, the scroll lock and the focus trap all come with the component.

Examples

Title content

The icon slot displays a decorative icon before the title. The title slot replaces the title content and defaults to the title prop. Custom titles retain the <h2> semantics and provide the dialog’s accessible name.

<script setup lang="ts">
  import { Settings } from '@lucide/vue'
  import { Button, Dialog, Text } from '@hina-ui/vue'
</script>

<template>
  <Dialog title="Dialog title">
    <Button variant="outline" tone="neutral">Title slots</Button>
    <template #icon><Settings /></template>
    <template #title>Custom title</template>
    <template #content>
      <Text>The icon and title content are provided through separate slots.</Text>
    </template>
  </Dialog>
</template>
vue

Close button

closable defaults to true. Set it to false to hide only the close button. Escape and clicks on the scrim still close the dialog; locked controls these two behaviours. The header’s close button is always omitted when header is false.

<script setup lang="ts">
  import { Button, Dialog, Text } from '@hina-ui/vue'
</script>

<template>
  <Dialog title="Hidden close button" :closable="false">
    <Button variant="outline" tone="neutral">Hidden close button</Button>
    <template #content>
      <Text>Press Escape, click the scrim or use the footer button to close.</Text>
    </template>
    <template #footer="{ close }">
      <Button @click="close">Close</Button>
    </template>
  </Dialog>
</template>
vue

Custom panel content

#body="{ close }" replaces the entire panel interior, including the default header, content and footer. The component adds no inner padding, section gaps or ScrollArea wrapper; the slot controls its own padding and scrolling. An empty slot does not restore the default layout.

In this mode, header, closable, and the icon, title, content and footer slots do not affect rendering. The required title and any provided description remain visually hidden. Size, placement, the scrim, focus containment and locked still apply. The slot’s close() method can close the dialog programmatically.

The example places actions at the top and keeps a toolbar at the bottom. Textarea grows with its content, while ScrollArea controls scrolling in the middle.

<script setup lang="ts">
  import { computed, ref } from 'vue'
  import { AtSign, Globe, ImagePlus, Smile } from '@lucide/vue'
  import {
    Avatar,
    Button,
    CloseButton,
    Dialog,
    IconButton,
    Image,
    Inline,
    ScrollArea,
    Stack,
    Text,
    Textarea,
  } from '@hina-ui/vue'

  const content = ref('')
  const attached = ref(false)
  const count = computed(() => Array.from(content.value).length)
  const overLimit = computed(() => count.value > 280)
</script>

<template>
  <Dialog title="New post" placement="top" class="max-w-[600px]">
    <Button variant="outline" tone="neutral">Custom panel</Button>
    <template #body="{ close }">
      <Stack gap="none" class="min-h-0">
        <Inline justify="between" class="border-line shrink-0 border-b px-3 py-2.5">
          <Button variant="ghost" tone="neutral" size="sm" @click="close">Cancel</Button>
          <Button size="sm" :disabled="(!content.trim() && !attached) || overLimit" @click="close">
            Post
          </Button>
        </Inline>

        <ScrollArea class="min-h-0">
          <Inline align="start" :wrap="false" class="gap-3 p-4">
            <Avatar src="/avatars/paper.webp" name="Shion" />
            <Stack gap="sm" class="min-w-0 flex-1">
              <Stack gap="xs">
                <Text weight="medium">Shion</Text>
                <Inline gap="xs">
                  <Globe class="text-muted size-3.5" aria-hidden="true" />
                  <Text size="xs" tone="muted">Public</Text>
                </Inline>
              </Stack>
              <Textarea
                v-model="content"
                variant="bare"
                :autosize="{ minRows: 5 }"
                :invalid="overLimit"
                aria-label="Post content"
                placeholder="Share a discovery, a recommendation, or a thought…"
                class="[--hn-textarea-px:0px]"
              />
              <Stack v-if="attached" class="relative">
                <Image
                  src="/sample.webp"
                  alt="Sample image"
                  class="aspect-video w-full rounded-md"
                />
                <CloseButton
                  label="Remove image"
                  class="bg-surface/90 absolute top-2 right-2 shadow-sm"
                  @click="attached = false"
                />
              </Stack>
            </Stack>
          </Inline>
        </ScrollArea>

        <Inline justify="between" :wrap="false" class="border-line shrink-0 border-t px-3 py-2">
          <Inline gap="xs">
            <IconButton
              label="Add sample image"
              size="sm"
              :aria-pressed="attached"
              @click="attached = !attached"
            >
              <ImagePlus />
            </IconButton>
            <IconButton label="Insert emoji" size="sm" @click="content += '😊'">
              <Smile />
            </IconButton>
            <IconButton label="Insert @" size="sm" @click="content += '@'">
              <AtSign />
            </IconButton>
          </Inline>
          <Text
            as="span"
            size="xs"
            :tone="overLimit ? 'danger' : 'muted'"
            class="shrink-0 tabular-nums"
          >
            {{ count }} / 280
          </Text>
        </Inline>
      </Stack>
    </template>
  </Dialog>
</template>
vue

Sizes

size sets the panel’s maximum width and defaults to md. The five widths are listed below; the actual width is constrained by the viewport.

sizeMaximum width
sm24rem
md28rem
lg36rem
xl42rem
2xl56rem
<script setup lang="ts">
  import { Button, Dialog, Inline, Text } from '@hina-ui/vue'
</script>

<template>
  <Inline align="center" class="gap-6">
    <Dialog
      v-for="size in ['sm', 'md', 'lg', 'xl', '2xl'] as const"
      :key="size"
      :size="size"
      :title="`Size ${size}`"
    >
      <Button variant="outline" tone="neutral">{{ size }}</Button>
      <template #content>
        <Text>The width follows size, while the height never runs past the viewport.</Text>
      </template>
    </Dialog>
  </Inline>
</template>
vue

Custom width

class styles the panel. Use max-w-[40rem] or max-w-[52rem] to override the preset maximum width. With the default placement, narrow screens still use the available width with space on both sides.

<script setup lang="ts">
  import { Button, Dialog, Inline, Text } from '@hina-ui/vue'

  const widths = [
    { label: '40rem', class: 'max-w-[40rem]' },
    { label: '52rem', class: 'max-w-[52rem]' },
  ]
</script>

<template>
  <Inline>
    <Dialog v-for="width in widths" :key="width.label" :title="width.label" :class="width.class">
      <Button variant="outline" tone="neutral">{{ width.label }}</Button>
      <template #content>
        <Text>
          The class sets the maximum width; narrow screens remain constrained by the viewport.
        </Text>
      </template>
    </Dialog>
  </Inline>
</template>
vue

Placement

Without placement, the dialog is centred on a wide screen and sits along the bottom at the available width on a narrow one. center always centres it, top aligns it to the top, and bottom aligns it to the bottom. top slides in from above and sits 1rem from the top with at least 1rem of space at the sides. Its width remains controlled by size and constrained by the viewport.

<script setup lang="ts">
  import { Button, Dialog, Inline, Text } from '@hina-ui/vue'
</script>

<template>
  <Inline align="center" class="gap-6">
    <Dialog title="Centred" placement="center">
      <Button variant="outline" tone="neutral">center</Button>
      <template #content>
        <Text>Stays centred at every screen width.</Text>
      </template>
    </Dialog>
    <Dialog title="Along the top" placement="top">
      <Button variant="outline" tone="neutral">top</Button>
      <template #content>
        <Text>Slides in from above with space at the top, including on narrow screens.</Text>
      </template>
    </Dialog>
    <Dialog title="Along the bottom" placement="bottom">
      <Button variant="outline" tone="neutral">bottom</Button>
      <template #content>
        <Text>Slides up from the bottom and keeps its rounded corners.</Text>
      </template>
    </Dialog>
    <Dialog title="Following the screen">
      <Button variant="outline" tone="neutral">default</Button>
      <template #content>
        <Text>
          Centred on a wide screen; on a narrow one it sits along the bottom and fills the width.
          Narrow the window and open it again to see it.
        </Text>
      </template>
    </Dialog>
  </Inline>
</template>
vue

Long content

In the default layout, content past the available height scrolls inside the content slot while the title and footer stay put. The panel itself never runs past the viewport.

<script setup lang="ts">
  import { Button, Dialog, Stack, Text } from '@hina-ui/vue'

  const terms = Array.from(
    { length: 30 },
    (_, i) =>
      `Clause ${i + 1}: using the service means you accept this term, which is here only as placeholder text, long enough that the body actually scrolls.`,
  )
</script>

<template>
  <Dialog title="Terms of service" description="Please read them before you continue.">
    <Button variant="outline" tone="neutral">Read the terms</Button>
    <template #content>
      <Stack gap="sm">
        <Text v-for="line in terms" :key="line">{{ line }}</Text>
      </Stack>
    </template>
    <template #footer="{ close }">
      <Button variant="soft" tone="neutral" @click="close">Decline</Button>
      <Button @click="close">Accept</Button>
    </template>
  </Dialog>
</template>
vue

Scroll viewport

Use the component ref’s viewport to access the actual scrollable element of its built-in ScrollArea. Read scrollTop, call scrollTo(), or pass it to scroll listeners and observers.

viewport has type HTMLElement | undefined. It is undefined before the content scroll area initializes, without a content slot, and after content unmounts. It remains available during exit and updates to a new element on reopening. Watch () => modal.value?.viewport to act when ready or attach listeners, and remove listeners in the watch cleanup callback.

The body slot replaces the built-in scroll area, so viewport is undefined. Reference any custom scroll area directly.

The example uses Button to scroll with viewport.scrollTo().

<script setup lang="ts">
  import { ref } from 'vue'
  import { Button, Dialog, Stack, Text } from '@hina-ui/vue'

  const modal = ref<InstanceType<typeof Dialog>>()

  function scrollTo(position: 'start' | 'end') {
    const viewport = modal.value?.viewport
    if (!viewport) return
    viewport.scrollTo({ top: position === 'start' ? 0 : viewport.scrollHeight })
  }
</script>

<template>
  <Dialog
    ref="modal"
    title="Scroll viewport"
    description="The title and footer stay fixed while the body scrolls."
  >
    <Button variant="outline" tone="neutral">Open</Button>
    <template #content>
      <Stack gap="sm">
        <Text v-for="index in 30" :key="index">
          Line {{ index }} The title and footer stay fixed while this content scrolls.
        </Text>
      </Stack>
    </template>
    <template #footer="{ close }">
      <Button variant="soft" tone="neutral" :disabled="!modal?.viewport" @click="scrollTo('start')">
        Top
      </Button>
      <Button variant="soft" tone="neutral" :disabled="!modal?.viewport" @click="scrollTo('end')">
        Bottom
      </Button>
      <Button @click="close">Close</Button>
    </template>
  </Dialog>
</template>
vue

Controlled

open supports two-way binding. Leaving out the default slot renders no trigger, so the dialog can only be opened from outside.

Currently closed

<script setup lang="ts">
  import { ref } from 'vue'
  import { Button, Dialog, Inline, Text } from '@hina-ui/vue'

  const open = ref(false)
</script>

<template>
  <Inline align="center">
    <Button variant="outline" tone="neutral" @click="open = true">Open from outside</Button>
    <Text tone="muted" size="sm">Currently {{ open ? 'open' : 'closed' }}</Text>
    <Dialog
      v-model:open="open"
      title="A dialog without a trigger"
      description="Outside state controls it."
    >
      <template #content>
        <Text>Leaving out the default slot renders no trigger, so `open` is the only way in.</Text>
      </template>
      <template #footer="{ close }">
        <Button @click="close">Close</Button>
      </template>
    </Dialog>
  </Inline>
</template>
vue

Locked

With locked, neither Escape nor a click on the scrim closes the dialog, and a rendered close button becomes disabled. Closing through open still works. The dialog is locked in the same way while a Form inside it is submitting, whether or not locked is set.

<script setup lang="ts">
  import { ref } from 'vue'
  import { Button, Dialog, Text } from '@hina-ui/vue'

  const open = ref(false)
  const submitting = ref(false)

  function submit() {
    submitting.value = true
    setTimeout(() => {
      submitting.value = false
      open.value = false
    }, 2000)
  }
</script>

<template>
  <Dialog
    v-model:open="open"
    title="Import a library"
    description="Please keep this dialog open while the import runs."
    :locked="submitting"
  >
    <Button variant="outline" tone="neutral">Import</Button>
    <template #content>
      <Text>
        {{
          submitting
            ? 'Importing. It closes on its own in two seconds.'
            : 'Starting the import locks the dialog for two seconds.'
        }}
      </Text>
    </template>
    <template #footer="{ close }">
      <Button variant="soft" tone="neutral" :disabled="submitting" @click="close">Cancel</Button>
      <Button :loading="submitting" @click="submit">Start import</Button>
    </template>
  </Dialog>
</template>
vue

Form inside a dialog

While a Form in the content slot is submitting, the dialog locks itself, and it unlocks when the submit ends or the form unmounts. The submitting slot prop of body, content and footer is true meanwhile.

The footer buttons in the example sit outside the form: the save button sets type="submit", points at the id of the form with the form attribute and binds loading to submitting; the cancel button binds disabled to submitting. Once the submit completes, the dialog is closed through open.

<script setup lang="ts">
  import { reactive, ref } from 'vue'
  import * as v from 'valibot'
  import { Button, Dialog, Form, FormField, Input } from '@hina-ui/vue'

  const schema = v.object({
    name: v.pipe(v.string('Enter a name'), v.nonEmpty('Enter a name')),
    email: v.pipe(
      v.string('Enter an email'),
      v.nonEmpty('Enter an email'),
      v.email('That is not a valid email'),
    ),
  })

  const open = ref(false)
  const values = reactive({ name: 'Hoshimi Shion', email: 'shion@example.com' })

  async function save() {
    await new Promise(resolve => setTimeout(resolve, 1500))
    open.value = false
  }
</script>

<template>
  <Dialog
    v-model:open="open"
    title="Edit profile"
    description="The dialog is locked while saving and closes when it is done."
  >
    <Button variant="outline" tone="neutral">Edit profile</Button>
    <template #content>
      <Form id="profile-form" :values="values" :rules="schema" @submit="save">
        <FormField name="name" label="Name">
          <Input v-model="values.name" />
        </FormField>
        <FormField name="email" label="Email">
          <Input v-model="values.email" type="email" />
        </FormField>
      </Form>
    </template>
    <template #footer="{ close, submitting }">
      <Button variant="soft" tone="neutral" :disabled="submitting" @click="close">Cancel</Button>
      <Button type="submit" form="profile-form" :loading="submitting">Save</Button>
    </template>
  </Dialog>
</template>
vue

Behaviour

  • The page is locked from scrolling while the dialog is open, focus is trapped inside the panel, and it returns to the trigger on close.
  • Escape or a click on the scrim closes the dialog; locked disables both.
  • A press on the scrim or focus moving outside fires interactOutside first; calling preventDefault() keeps it open. Content inside an OverlayBranch does not count as outside.
  • A Form submitting inside the dialog counts as locked; a form outside the dialog does not affect it, and a nested dialog only follows the forms inside itself.
  • The default body uses ScrollArea.

Accessibility

  • The panel is a role="dialog", with title and description wired to aria-labelledby and aria-describedby.
  • The title renders as an <h2>. Hiding the header or providing body retains a visually hidden title generated from the title prop.
  • The close button carries an accessible name taken from the current language.

API

Dialog

Prop
Type
Default
Description
title
string
—
Required. The dialog title
description
string
—
The line under the title
size
'sm' | 'md' | 'lg' | 'xl' | '2xl'
'md'
Maximum width of the panel
placement
'center' | 'top' | 'bottom'
—
Follows the screen width when omitted
header
boolean
true
Show the header and its close button
closable
boolean
true
Show the close button within the header
locked
boolean
false
Whether the user is kept from closing
open
boolean
—
Whether it is open; supports v-model
class
string
—
Classes appended to the panel
Slot
Payload
Description
default
—
The trigger; omit it to render none
icon
—
Decorative icon before the title
title
—
Title content; defaults to the title prop
body
{ close, submitting }
Entire interior; replaces the default header, content and footer
content
{ close, submitting }
The body, scrolling when it is too tall
footer
{ close, submitting }
The actions along the bottom

close closes the dialog; submitting is true while a Form inside the dialog is submitting.

Event
Payload
Description
interactOutside
PointerDownOutsideEvent | FocusOutsideEvent
Fires on a press or focus outside; preventDefault() keeps it open

Exposed instance

Property
Type
Description
viewport
HTMLElement | undefined
Actual scrollable element of the built-in ScrollArea; available after initialization and cleared when content unmounts