A native <dialog> opened modally, so the focus trap, the inert page behind it and the top layer all come from the browser. VDialogAlert is the same box narrowed to a question that must be answered.
Usage
vue
<script setup lang="ts">
import { ref } from 'vue'
import { VButton, VDialog, VTypography } from 'vectis-ui'
const open = ref(false)
</script>
<template>
<VDialog v-model:open="open" title="Share this file" subtitle="Choose who can open it.">
<template #trigger="{ triggerProps }">
<VButton v-bind="triggerProps">Share</VButton>
</template>
<VTypography>Anyone with the link can open this file.</VTypography>
<template #footer>
<VButton variant="ghost" tone="neutral" @click="open = false">Cancel</VButton>
<VButton @click="open = false">Share</VButton>
</template>
</VDialog>
</template>
Examples
Width
width takes a CSS length in any unit, 400px by default. The dialog never exceeds the viewport and keeps a margin on either side.
vue
<script setup lang="ts">
import { ref } from 'vue'
import { VButton, VDialog, VTypography } from 'vectis-ui'
/* One dialog per width, each opened by its own button. Whatever is asked for, the
dialog is never allowed past the viewport, margins included. */
const WIDTHS = ['320px', '480px', '40rem']
const opened = ref<string | null>(null)
</script>
<template>
<div class="row">
<VButton
v-for="width in WIDTHS"
:key="width"
variant="outline"
tone="neutral"
@click="opened = width"
>
{{ width }}
</VButton>
</div>
<VDialog
v-for="width in WIDTHS"
:key="width"
:width="width"
:open="opened === width"
:title="`A ${width} dialog`"
subtitle="The width is a CSS length, in any unit."
@update:open="(value) => !value && (opened = null)"
>
<VTypography>
Past the viewport the dialog stops growing and keeps a margin on either side, so a width set
in pixels never has to be defended against a narrow screen.
</VTypography>
<template #footer>
<VButton @click="opened = null">Close</VButton>
</template>
</VDialog>
</template>
<style scoped>
.row {
display: flex;
flex-wrap: wrap;
gap: var(--vectis-space-2);
}
</style>
Long content
Only the body scrolls, the header and the footer staying where they are. Hairlines appear under the header and above the footer while content is passing behind them.
vue
<script setup lang="ts">
import { ref } from 'vue'
import { VButton, VDialog, VTypography } from 'vectis-ui'
const open = ref(false)
const CLAUSES = [
'The service is provided as it stands, and the clauses below say what that means in practice. Read them once: they are short, and every one of them is there because someone asked.',
'An account belongs to one person. Sharing credentials is what makes an audit trail useless, and an audit trail is the only thing that can tell us who changed what when something goes wrong.',
'Content you upload stays yours. We store it, serve it back to you, and nothing else happens to it: it is not read, not indexed for anyone but you, and never used to train anything.',
'Backups are kept for thirty days. After that a deletion is final and cannot be appealed, so export anything you may want before you remove it rather than after.',
'Usage limits are published on the pricing page and apply per organisation rather than per seat. Going past one slows requests down; it never drops them.',
'We announce a breaking change at least ninety days before it ships, by email to every administrator and in the changelog. A deprecation keeps working for the whole of that period.',
'Support is answered within one working day, Monday to Friday, in English and in French. An incident affecting availability is answered whatever the day.',
'Either side may end the agreement with thirty days of notice, in writing. Ending it does not delete anything: the export stays available for the thirty days that follow.',
'A refund covers the unused remainder of a period, counted in whole days, and is paid back by the route the payment came in on.',
'Availability is measured monthly and published. A month below the figure we commit to is credited without your having to ask for it.',
'Personal data is processed in the European Union. The list of subprocessors is public, and a new one is announced thirty days before it is used.',
'Security reports are welcome at the address on the security page. We answer within two working days and never take action against a reporter acting in good faith.',
'An account inactive for two years is closed after three warnings sent a month apart, the last of them naming the date.',
'The API is versioned in its path. A version stays supported for at least eighteen months after its successor ships.',
'Rate limits are returned in the response headers rather than documented in one place and forgotten. What the headers say is what applies.',
'You may resell what you build on the service. You may not resell the service itself as if it were yours.',
'Trademarks stay with whoever owns them. Using our name to say what your product integrates with is fine; using it as if it were your own is not.',
'A price change applies at the next renewal and never in the middle of a period, with sixty days of notice.',
'Taxes are added where they are owed, worked out from the billing address you give us.',
'An invoice disputed in writing within sixty days is looked at; past that it is taken as accepted.',
'These terms are governed by French law, and disputes that cannot be settled between us are heard in Paris.',
'A clause a court sets aside is removed and the rest stands, rather than the whole agreement falling with it.',
'Nothing here removes a right the law gives you, whichever of the two says otherwise.',
'The version that applies is the one published on the day you agreed to it, and every version is kept and dated.',
]
</script>
<template>
<VDialog v-model:open="open" title="Terms of service" subtitle="Scroll to read all of it.">
<template #trigger="{ triggerProps }">
<VButton v-bind="triggerProps">Read the terms</VButton>
</template>
<VTypography v-for="(clause, index) in CLAUSES" :key="clause" class="clause">
{{ index + 1 }}. {{ clause }}
</VTypography>
<template #footer>
<VButton variant="ghost" tone="neutral" @click="open = false">Decline</VButton>
<VButton @click="open = false">Accept</VButton>
</template>
</VDialog>
</template>
<style scoped>
.clause {
margin-block-end: var(--vectis-space-3);
}
</style>
Custom header
The #header slot replaces the whole title and subtitle block. The title prop is then ignored, so name the dialog with an aria-label instead. The close cross is untouched.
vue
<script setup lang="ts">
import { ref } from 'vue'
import { VAvatar, VButton, VDialog, VTypography } from 'vectis-ui'
const open = ref(false)
const following = ref(false)
</script>
<template>
<!-- The slot replaces the title, so the dialog is named with an `aria-label` instead:
there is no longer a `title` prop for it to point at. -->
<VDialog v-model:open="open" width="440px" aria-label="Nadia Rousseau">
<template #trigger="{ triggerProps }">
<VButton v-bind="triggerProps">Open the profile</VButton>
</template>
<template #header>
<div class="profile">
<VAvatar name="Nadia Rousseau" size="lg" />
<div class="identity">
<VTypography as="h2" variant="heading-4">Nadia Rousseau</VTypography>
<VTypography variant="body-sm" tone="muted">Design systems, Paris</VTypography>
</div>
<VButton
size="sm"
:variant="following ? 'outline' : 'solid'"
:tone="following ? 'neutral' : 'accent'"
@click="following = !following"
>
{{ following ? 'Following' : 'Follow' }}
</VButton>
</div>
</template>
<VTypography>
Maintains the component library and reviews every change to the token source. Ask her about
anything to do with theming.
</VTypography>
<template #footer>
<VButton variant="ghost" tone="neutral" @click="open = false">Close</VButton>
<VButton @click="open = false">Send a message</VButton>
</template>
</VDialog>
</template>
<style scoped>
.profile {
display: flex;
flex: 1;
align-items: center;
gap: var(--vectis-space-3);
}
.identity {
flex: 1;
min-inline-size: 0;
}
</style>
Header actions
The #header-actions slot adds controls to the header, rendered before the close cross so that the cross stays at the edge.
vue
<script setup lang="ts">
import { ref } from 'vue'
import {
VButton,
VDialog,
VIconButton,
VMenu,
VMenuItem,
VMenuSeparator,
VTypography,
} from 'vectis-ui'
import { info, more_horiz as moreHoriz } from 'vectis-ui/icons'
const open = ref(false)
const details = ref(false)
</script>
<template>
<VDialog v-model:open="open" width="480px" title="Quarterly report" subtitle="report-q3.pdf">
<template #trigger="{ triggerProps }">
<VButton v-bind="triggerProps">Open the preview</VButton>
</template>
<!-- Rendered before the close cross, which keeps the cross at the edge where the
reader looks for it. -->
<template #header-actions>
<VIconButton
:icon="info"
label="File details"
variant="ghost"
tone="neutral"
size="sm"
@click="details = !details"
/>
<VMenu>
<template #trigger="{ triggerProps }">
<VIconButton
v-bind="triggerProps"
:icon="moreHoriz"
label="More actions"
variant="ghost"
tone="neutral"
size="sm"
/>
</template>
<VMenuItem label="Rename" />
<VMenuItem label="Duplicate" />
<VMenuSeparator />
<VMenuItem label="Delete" tone="danger" />
</VMenu>
</template>
<VTypography v-if="details" tone="muted" variant="body-sm">
PDF, 2.4 MB, updated on 12 September.
</VTypography>
<VTypography>
The report covers the third quarter and supersedes the figures circulated in August.
</VTypography>
<template #footer>
<VButton @click="open = false">Close</VButton>
</template>
</VDialog>
</template>
Dismissal
hideClose takes the cross away, persistentBackdrop ignores a click outside and persistentEscape ignores the key. Closing every route off makes a footer mandatory.
vue
<script setup lang="ts">
import { ref } from 'vue'
import { VButton, VDialog, VTypography } from 'vectis-ui'
const noCross = ref(false)
const noBackdrop = ref(false)
const locked = ref(false)
</script>
<template>
<div class="row">
<VButton variant="outline" tone="neutral" @click="noCross = true">No cross</VButton>
<VButton variant="outline" tone="neutral" @click="noBackdrop = true">
Backdrop does nothing
</VButton>
<VButton variant="outline" tone="neutral" @click="locked = true">Footer only</VButton>
</div>
<!-- Escape and the backdrop still work, so the reader is never trapped. -->
<VDialog
v-model:open="noCross"
hide-close
title="Publish this version?"
subtitle="Escape and a click outside still close it."
>
<VTypography>
Taking the cross away suits a short decision, where the footer already says what the two ways
out are.
</VTypography>
<template #footer>
<VButton variant="ghost" tone="neutral" @click="noCross = false">Cancel</VButton>
<VButton @click="noCross = false">Publish</VButton>
</template>
</VDialog>
<VDialog
v-model:open="noBackdrop"
persistent-backdrop
title="Unsaved changes"
subtitle="A click outside is ignored. Escape and the cross are not."
>
<VTypography>
Worth it for a form in progress, where a stray click outside would lose what has been typed.
</VTypography>
<template #footer>
<VButton variant="ghost" tone="neutral" @click="noBackdrop = false">Discard</VButton>
<VButton @click="noBackdrop = false">Keep editing</VButton>
</template>
</VDialog>
<!-- Both refused: the footer is the only way out. Escape alone cannot be refused while
the backdrop still closes, so the two go together. -->
<VDialog
v-model:open="locked"
hide-close
persistent-backdrop
persistent-escape
title="Accept the new terms"
subtitle="One of the two buttons, and nothing else."
>
<VTypography>
Nothing dismisses this by accident. Answer explicitly, and give every such dialog a footer:
without one there is no way out at all.
</VTypography>
<template #footer>
<VButton variant="ghost" tone="neutral" @click="locked = false">Read them again</VButton>
<VButton @click="locked = false">Accept</VButton>
</template>
</VDialog>
</template>
<style scoped>
.row {
display: flex;
flex-wrap: wrap;
gap: var(--vectis-space-2);
}
</style>
Alert dialog
VDialogAlert is this dialog with its options fixed: it is announced as an alert, and there is no cross, no Escape and no click outside, so its footer is not optional.
vue
<script setup lang="ts">
import { ref } from 'vue'
import { VButton, VDialogAlert, VTypography } from 'vectis-ui'
const open = ref(false)
const deleted = ref(false)
function confirmDelete() {
deleted.value = true
open.value = false
}
</script>
<template>
<div class="demo">
<VDialogAlert
v-model:open="open"
title="Delete this project?"
subtitle="Its issues, branches and releases go with it."
>
<template #trigger="{ triggerProps }">
<VButton v-bind="triggerProps" tone="danger">Delete Meridian</VButton>
</template>
<VTypography>
There is no cross, Escape does nothing and a click outside does nothing: the two buttons
below are the only way out, which is why supplying them is not optional.
</VTypography>
<template #footer>
<VButton variant="ghost" tone="neutral" @click="open = false">Cancel</VButton>
<VButton tone="danger" @click="confirmDelete">Delete</VButton>
</template>
</VDialogAlert>
<VTypography v-if="deleted" tone="danger" variant="body-sm">Meridian was deleted.</VTypography>
</div>
</template>
<style scoped>
.demo {
display: grid;
justify-items: start;
gap: var(--vectis-space-3);
}
</style>
API
Props
VDialog
Prop
Type
Default
title
string
none
The title of the dialog, which also names it for assistive technology. It is ignored when the #header slot replaces the whole header.
subtitle
string
none
A line under the title, explaining what the dialog is asking.
width
number | string
none
How wide the dialog is: a number is read as pixels, a string as any CSS length. Left out, it takes the --vectis-control-size-dialog-width token, 400px by default. It is never allowed to exceed the width of the viewport.
role
DialogRole'dialog' | 'alertdialog'
'dialog'
What kind of dialog this is. alertdialog is for one that must be answered explicitly, and it makes screen readers announce it more insistently.
hideClose
boolean
false
Takes the close cross out of the header, leaving the reader with Escape, the backdrop and whatever the footer offers.
persistentBackdrop
boolean
false
Stops a click outside the dialog from closing it.
persistentEscape
boolean
false
Stops the Escape key from closing the dialog. Refusing Escape while the backdrop still closes cannot be expressed natively, so both routes are then allowed.
closeLabel
string
none
What the close cross does, in words. It falls back to the design system dictionary.
v-model:open
boolean
false
Whether the dialog is showing. It starts closed, and it is bidirectional: the browser writes back to it whenever the dialog closes on its own, through Escape or the backdrop, so you never have to reset it by hand.
VDialogAlert
Prop
Type
Default
title
string
none
The question being asked, which also names the dialog for assistive technology. It is ignored when the #header slot replaces the whole header.
subtitle
string
none
A line under the title, spelling out the consequences of the answer.
width
number | string
none
How wide the dialog is: a number is read as pixels, a string as any CSS length. Left out, it takes the --vectis-control-size-dialog-width token, 400px by default. It is never allowed to exceed the width of the viewport.
v-model:open
boolean
false
Whether the alert is showing. It starts closed, and closing writes back to it.
Slots
VDialog
Slot
Type
default
{}
The body of the dialog. This is the part that scrolls when there is too much of it.
header
{}
Replaces the title and subtitle block with content of your own.
header-actions
{}
Extra controls in the header, placed before the close cross: a menu, a full-screen toggle.
The button that opens the dialog. Bind the triggerProps it receives onto it. It stays rendered at all times, unlike the dialog itself.
VDialogAlert
Slot
Type
default
{}
What the alert says.
header
{}
Replaces the title and subtitle block with content of your own.
header-actions
{}
Extra controls in the header, where a dialog puts them before its cross: a link to help, for instance. An alert has no cross, so they sit at the end of the header alone.
footer
{}
The buttons that answer the alert. They are not optional: nothing else can close this dialog.
The button that opens the alert. Bind the triggerProps it receives onto it.
Types
The types the tables above name, written as the library declares them. The ones carrying export can be imported from vectis-ui to type your own code; the others are the shape of what a slot hands out.