Alert
A blocking confirmation for actions that cannot be undone.
'use client'
import { useState } from 'react'
import { Alert, AlertActions, AlertDescription, AlertTitle } from '@/components/alert'
import { Button } from '@/components/button'
export default function Example({ revokeKey }: { revokeKey: () => void }) {
let [isOpen, setIsOpen] = useState(false)
return (
<>
<Button type="button" outline onClick={() => setIsOpen(true)}>
Revoke key
</Button>
<Alert open={isOpen} onClose={setIsOpen}>
<AlertTitle>Revoke the Production API key?</AlertTitle>
<AlertDescription>
Requests signed with sk_live_…4f2a start failing right away. Apps that still use it need a new key.
</AlertDescription>
<AlertActions>
<Button plain onClick={() => setIsOpen(false)}>
Keep key
</Button>
<Button
color="red"
onClick={() => {
revokeKey()
setIsOpen(false)
}}
>
Revoke key
</Button>
</AlertActions>
</Alert>
</>
)
}<script setup lang="ts">
import { ref } from 'vue'
import Alert from '@/components/alert.vue'
import AlertActions from '@/components/alert-actions.vue'
import AlertDescription from '@/components/alert-description.vue'
import AlertTitle from '@/components/alert-title.vue'
import Button from '@/components/button.vue'
const props = defineProps<{ revokeKey: () => void }>()
const isOpen = ref(false)
function revoke() {
props.revokeKey()
isOpen.value = false
}
</script>
<template>
<Button type="button" outline @click="isOpen = true">Revoke key</Button>
<Alert :open="isOpen" @close="isOpen = false">
<AlertTitle>Revoke the Production API key?</AlertTitle>
<AlertDescription>
Requests signed with sk_live_…4f2a start failing right away. Apps that still use it need a new key.
</AlertDescription>
<AlertActions>
<Button plain @click="isOpen = false">Keep key</Button>
<Button color="red" @click="revoke">Revoke key</Button>
</AlertActions>
</Alert>
</template>Installation
Copy these files into the folder where you keep your components. They import each other with relative paths, so the folder can live anywhere. The packages they rely on are listed on the Installation page.
'use client'
import * as Headless from '@headlessui/react'
import clsx from 'clsx'
import type React from 'react'
import { useModalStackPosition } from './modal-stack'
import { Text } from './text'
const sizes = {
xs: 'sm:max-w-xs',
sm: 'sm:max-w-sm',
md: 'sm:max-w-md',
lg: 'sm:max-w-lg',
xl: 'sm:max-w-xl',
'2xl': 'sm:max-w-2xl',
'3xl': 'sm:max-w-3xl',
'4xl': 'sm:max-w-4xl',
'5xl': 'sm:max-w-5xl',
}
const enterLeave = 'transition duration-200 motion-reduce:duration-0 data-enter:ease-out data-leave:ease-in'
const backdrop = clsx(enterLeave, 'fixed inset-0 w-screen bg-zinc-950/15 focus:outline-0 data-closed:opacity-0 dark:bg-zinc-950/50')
const stackedLevel = [
'origin-top transform-gpu transition-[translate,scale] duration-200 ease-out motion-reduce:transition-none',
'sm:translate-y-[calc(var(--stack-depth)*-1rem)] sm:scale-[calc(1-0.04*var(--stack-depth))]',
'after:pointer-events-none after:absolute after:inset-0 after:rounded-2xl after:bg-zinc-950/30 after:opacity-[calc(var(--stack-cover,0)*0.5)] dark:after:bg-zinc-950/50',
'after:transition-opacity after:duration-200 motion-reduce:after:transition-none',
]
const panel = [
'w-full rounded-2xl bg-white p-8 shadow-lg ring-1 ring-zinc-950/10 sm:p-6 dark:bg-zinc-900 dark:ring-white/10 forced-colors:outline',
enterLeave,
'will-change-transform data-closed:scale-95 data-closed:opacity-0',
]
type AlertProps = {
size?: keyof typeof sizes
className?: string
children: React.ReactNode
} & Omit<Headless.DialogProps, 'as' | 'className'>
export function Alert({ size = 'md', className, children, ...props }: AlertProps) {
let { depth, cover, isBase, inStack } = useModalStackPosition('dialog')
let stackVariables = { '--stack-depth': depth, '--stack-cover': cover } as React.CSSProperties
return (
<Headless.Dialog role="alertdialog" {...props} inert={depth > 0 || undefined}>
{isBase && <Headless.DialogBackdrop transition className={backdrop} />}
<div className="fixed inset-0 w-screen pt-6 sm:pt-0 overflow-y-auto">
<div
className={clsx(
'grid min-h-full grid-rows-[1fr_auto_1fr] justify-items-center p-8 sm:p-4',
inStack ? 'sm:grid-rows-[12vh_auto_1fr]' : 'sm:grid-rows-[1fr_auto_3fr]'
)}
>
<div style={stackVariables} className={clsx(sizes[size], 'relative row-start-2 w-full', stackedLevel)}>
<Headless.DialogPanel transition className={clsx(className, panel)}>
{children}
</Headless.DialogPanel>
</div>
</div>
</div>
</Headless.Dialog>
)
}
export function AlertTitle({ className, ...props }: { className?: string } & Omit<Headless.DialogTitleProps, 'as' | 'className'>) {
return (
<Headless.DialogTitle
{...props}
className={clsx(
className,
'font-semibold text-zinc-950 dark:text-white text-base/6 sm:text-sm/6',
'text-center sm:text-left text-balance sm:text-wrap'
)}
/>
)
}
export function AlertDescription({
className,
...props
}: { className?: string } & Omit<Headless.DescriptionProps<typeof Text>, 'as' | 'className'>) {
return <Headless.Description as={Text} {...props} className={clsx(className, 'mt-2 text-pretty text-center sm:text-left')} />
}
export function AlertBody({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
return <div {...props} className={clsx(className, 'mt-4')} />
}
export function AlertActions({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
return (
<div
{...props}
className={clsx(className, 'mt-6 sm:mt-4 flex flex-col-reverse sm:flex-row items-center justify-end gap-3 *:w-full sm:*:w-auto')}
/>
)
}<script setup lang="ts">
import { hideOthers } from 'aria-hidden'
import { FocusScope } from 'reka-ui'
import { onBeforeUnmount, provide, ref, useId, watch } from 'vue'
import { DialogIdsKey, focusAutofocusElement, isEscapeForModal } from './dialog.vue'
import { useModalStackPosition } from './modal-stack'
import { useScrollLock } from './use-scroll-lock'
const sizes = {
xs: 'sm:max-w-xs',
sm: 'sm:max-w-sm',
md: 'sm:max-w-md',
lg: 'sm:max-w-lg',
xl: 'sm:max-w-xl',
'2xl': 'sm:max-w-2xl',
'3xl': 'sm:max-w-3xl',
'4xl': 'sm:max-w-4xl',
'5xl': 'sm:max-w-5xl',
}
const backdrop = 'fixed inset-0 z-50 w-screen bg-zinc-950/15 focus:outline-0 dark:bg-zinc-950/50'
const stackedLevel = [
'origin-top transform-gpu transition-[translate,scale] duration-200 ease-out motion-reduce:transition-none',
'sm:translate-y-[calc(var(--stack-depth)*-1rem)] sm:scale-[calc(1-0.04*var(--stack-depth))]',
'after:pointer-events-none after:absolute after:inset-0 after:rounded-2xl after:bg-zinc-950/30 after:opacity-[calc(var(--stack-cover,0)*0.5)] dark:after:bg-zinc-950/50',
'after:transition-opacity after:duration-200 motion-reduce:after:transition-none',
]
const panel =
'w-full rounded-2xl bg-white p-8 shadow-lg ring-1 ring-zinc-950/10 sm:p-6 dark:bg-zinc-900 dark:ring-white/10 forced-colors:outline'
const props = withDefaults(defineProps<{ open: boolean; size?: keyof typeof sizes }>(), { size: 'md' })
const emit = defineEmits<{ close: [] }>()
const id = useId()
const hasTitle = ref(false)
const hasDescription = ref(false)
const titleId = `${id}-title`
const descriptionId = `${id}-description`
provide(DialogIdsKey, { titleId, descriptionId, hasTitle, hasDescription })
const { depth, cover, isBase, inStack } = useModalStackPosition('dialog')
const locked = useScrollLock()
const rendered = ref(props.open)
const panelElement = ref<HTMLElement>()
let showOthers: (() => void) | undefined
watch(panelElement, (element) => {
showOthers?.()
showOthers = element && !inStack ? hideOthers(element) : undefined
})
function onAfterLeave() {
rendered.value = false
}
function onKeydown(event: KeyboardEvent) {
if (!isEscapeForModal(event) || cover.value > 0) return
event.stopPropagation()
emit('close')
}
function onOutsideMousedown(event: MouseEvent) {
if (event.target === event.currentTarget && cover.value === 0) emit('close')
}
watch(
() => props.open,
(open) => {
if (open) rendered.value = true
locked.value = open
},
{ immediate: true }
)
onBeforeUnmount(() => {
showOthers?.()
locked.value = false
})
</script>
<template>
<Teleport to="body">
<Transition
enter-active-class="transition duration-200 ease-out motion-reduce:duration-0"
enter-from-class="opacity-0"
leave-active-class="transition duration-200 ease-in motion-reduce:duration-0"
leave-to-class="opacity-0"
>
<div v-if="open && isBase" aria-hidden="true" :class="backdrop" />
</Transition>
<div v-if="rendered" class="fixed inset-0 z-50 w-screen pt-6 sm:pt-0 overflow-y-auto" @mousedown="onOutsideMousedown">
<div
:class="[
'grid min-h-full grid-rows-[1fr_auto_1fr] justify-items-center p-8 sm:p-4',
inStack ? 'sm:grid-rows-[12vh_auto_1fr]' : 'sm:grid-rows-[1fr_auto_3fr]',
]"
@mousedown="onOutsideMousedown"
>
<div :style="{ '--stack-depth': depth, '--stack-cover': cover }" :class="[sizes[size], 'relative row-start-2 w-full', stackedLevel]">
<Transition
appear
enter-active-class="transition duration-200 ease-out will-change-transform motion-reduce:duration-0"
enter-from-class="scale-95 opacity-0"
leave-active-class="transition duration-200 ease-in will-change-transform motion-reduce:duration-0"
leave-to-class="scale-95 opacity-0"
@after-leave="onAfterLeave"
>
<FocusScope v-if="open" as-child trapped loop @mount-auto-focus="focusAutofocusElement">
<div
ref="panelElement"
role="alertdialog"
aria-modal="true"
:inert="depth > 0"
:aria-labelledby="hasTitle ? titleId : undefined"
:aria-describedby="hasDescription ? descriptionId : undefined"
:class="panel"
@keydown="onKeydown"
>
<slot />
</div>
</FocusScope>
</Transition>
</div>
</div>
</div>
</Teleport>
</template>Component API
| Prop | Default | Description |
|---|---|---|
Alertbuilt on the Headless UI <Headless.Dialog> component | ||
open | - | Controls whether the alert is shown. |
onClose | - | Called when the user presses Escape or clicks the backdrop. |
size | md | Maximum width of the panel, from xs to 5xl. |
AlertTitlebuilt on the Headless UI <Headless.DialogTitle> component | ||
| This component does not expose any component-specific props. | ||
AlertDescriptionbuilt on the Headless UI <Headless.Description> component | ||
| This component does not expose any component-specific props. | ||
AlertBodyrenders a <div> element | ||
| This component does not expose any component-specific props. | ||
AlertActionsrenders a <div> element | ||
| This component does not expose any component-specific props. | ||
| Prop | Default | Description |
|---|---|---|
Alertbuilt on the Reka UI <FocusScope> component | ||
open | - | Controls whether the alert is shown. |
size | md | Maximum width of the panel, from xs to 5xl. |
@close | - | Emitted when the user presses Escape or clicks the backdrop. |
AlertTitlerenders a <h2> element | ||
| This component does not expose any component-specific props. | ||
AlertDescriptionbuilt on the <Text> component | ||
| This component does not expose any component-specific props. | ||
AlertBodyrenders a <div> element | ||
| This component does not expose any component-specific props. | ||
AlertActionsrenders a <div> element | ||
| This component does not expose any component-specific props. | ||
Usage
Keep alerts for decisions that lose data or cut off access. A short question as the title, the consequence in the description, and a primary action that repeats the verb of the title.
Confirm a destructive action
AlertTitle, AlertDescription and AlertActions cover most confirmations. The safe choice goes first as a plain button:
'use client'
import { useState } from 'react'
import { Alert, AlertActions, AlertDescription, AlertTitle } from '@/components/alert'
import { Button } from '@/components/button'
export default function Example({ revokeKey }: { revokeKey: () => void }) {
let [isOpen, setIsOpen] = useState(false)
return (
<>
<Button type="button" outline onClick={() => setIsOpen(true)}>
Revoke key
</Button>
<Alert open={isOpen} onClose={setIsOpen}>
<AlertTitle>Revoke the Production API key?</AlertTitle>
<AlertDescription>
Requests signed with sk_live_…4f2a start failing right away. Apps that still use it need a new key.
</AlertDescription>
<AlertActions>
<Button plain onClick={() => setIsOpen(false)}>
Keep key
</Button>
<Button
color="red"
onClick={() => {
revokeKey()
setIsOpen(false)
}}
>
Revoke key
</Button>
</AlertActions>
</Alert>
</>
)
}<script setup lang="ts">
import { ref } from 'vue'
import Alert from '@/components/alert.vue'
import AlertActions from '@/components/alert-actions.vue'
import AlertDescription from '@/components/alert-description.vue'
import AlertTitle from '@/components/alert-title.vue'
import Button from '@/components/button.vue'
const props = defineProps<{ revokeKey: () => void }>()
const isOpen = ref(false)
function revoke() {
props.revokeKey()
isOpen.value = false
}
</script>
<template>
<Button type="button" outline @click="isOpen = true">Revoke key</Button>
<Alert :open="isOpen" @close="isOpen = false">
<AlertTitle>Revoke the Production API key?</AlertTitle>
<AlertDescription>
Requests signed with sk_live_…4f2a start failing right away. Apps that still use it need a new key.
</AlertDescription>
<AlertActions>
<Button plain @click="isOpen = false">Keep key</Button>
<Button color="red" @click="revoke">Revoke key</Button>
</AlertActions>
</Alert>
</template>Ask for a typed confirmation
Put a form control in AlertBody when the action is severe enough to make the user type something first:
'use client'
import { useState } from 'react'
import { Alert, AlertActions, AlertBody, AlertDescription, AlertTitle } from '@/components/alert'
import { Button } from '@/components/button'
import { Input } from '@/components/input'
export default function Example({ deleteWorkspace }: { deleteWorkspace: () => void }) {
let [isOpen, setIsOpen] = useState(false)
let [name, setName] = useState('')
return (
<>
<Button type="button" color="red" onClick={() => setIsOpen(true)}>
Delete workspace
</Button>
<Alert open={isOpen} size="sm" onClose={setIsOpen}>
<AlertTitle>Delete this workspace?</AlertTitle>
<AlertDescription>Type northwind to confirm. Projects, members and invoices go with it.</AlertDescription>
<form
onSubmit={(event) => {
event.preventDefault()
deleteWorkspace()
setIsOpen(false)
}}
>
<AlertBody>
<Input
name="workspace"
aria-label="Workspace name"
placeholder="northwind"
value={name}
onChange={(event) => setName(event.target.value)}
/>
</AlertBody>
<AlertActions>
<Button plain onClick={() => setIsOpen(false)}>
Cancel
</Button>
<Button type="submit" color="red" disabled={name !== 'northwind'}>
Delete
</Button>
</AlertActions>
</form>
</Alert>
</>
)
}<script setup lang="ts">
import { ref } from 'vue'
import Alert from '@/components/alert.vue'
import AlertActions from '@/components/alert-actions.vue'
import AlertBody from '@/components/alert-body.vue'
import AlertDescription from '@/components/alert-description.vue'
import AlertTitle from '@/components/alert-title.vue'
import Button from '@/components/button.vue'
import Input from '@/components/input.vue'
const props = defineProps<{ deleteWorkspace: () => void }>()
const isOpen = ref(false)
const name = ref('')
function submit() {
props.deleteWorkspace()
isOpen.value = false
}
</script>
<template>
<Button type="button" color="red" @click="isOpen = true">Delete workspace</Button>
<Alert :open="isOpen" size="sm" @close="isOpen = false">
<AlertTitle>Delete this workspace?</AlertTitle>
<AlertDescription>Type northwind to confirm. Projects, members and invoices go with it.</AlertDescription>
<form @submit.prevent="submit">
<AlertBody>
<Input v-model="name" name="workspace" aria-label="Workspace name" placeholder="northwind" />
</AlertBody>
<AlertActions>
<Button plain @click="isOpen = false">Cancel</Button>
<Button type="submit" color="red" :disabled="name !== 'northwind'">Delete</Button>
</AlertActions>
</form>
</Alert>
</template>Customization
Panel width
The panel is md wide by default. Pass another size when the description runs long:
<Alert open={isOpen} size="lg" onClose={setIsOpen}>
{/* ... */}
</Alert><Alert :open="isOpen" size="lg" @close="isOpen = false">
<!-- ... -->
</Alert>Sizes go from xs to 5xl: xs, sm, md, lg, xl, 2xl, 3xl, 4xl and 5xl.
Accessibility
The panel has the alertdialog role, so screen readers announce it as an interruption. Focus stays inside it while it is open and returns to the trigger when it closes. Escape and a click on the backdrop both ask to close it, through onClose in React and the close event in Vue.
Initial focus
Mark the control the user needs first with autoFocus so they can start typing as soon as the alert opens:
Mark the control the user needs first with autofocus so they can start typing as soon as the alert opens:
<AlertBody>
<Input autoFocus name="workspace" aria-label="Workspace name" placeholder="northwind" />
</AlertBody><AlertBody>
<Input autofocus name="workspace" aria-label="Workspace name" placeholder="northwind" />
</AlertBody>