Illustrated alert dialogs
Five illustrated confirmation dialogs: spotlight, badge, circle, gradient and banner. Each carries a large decorative top region built purely from tokens, is self-contained (no radix), sized, and closes on backdrop click or Escape.
Installation
The styled layer is free and installs like any other ai2 component.
Run the following command
npx shadcn@latest add @ai2/alert-dialog-illustrationDependencies, the @ai2/tokens theme and the component file are installed together.
Install dependencies
npm install motion lucide-reactCopy the source
components/ui/alert-dialog-illustration.tsx"use client"
import * as React from "react"
import { AlertTriangle, Bell, CheckCircle, Info } from "lucide-react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { cn } from "@/lib/utils"
/* Styled alert-dialog "Illustration" family: 5 decorative confirmation modals.
Same self-contained shell as the essentials alert-dialog-styled (no radix, no
portal): fixed backdrop + centered fixed panel; a backdrop click, Escape or
Cancel closes it; Confirm calls onConfirm first and then closes; the panel
takes focus on open; AnimatePresence handles enter/exit, and under reduced
motion it fades or switches instantly. The difference: a LARGE decorative
full-width visual region above the panel (spotlight, badge, rings, gradient
band, banner) - all built from tokens, no real image. Color comes ONLY from
tokens, via alpha/gradient/radial color-mix(in oklab ...). */
export type StyledSize = "sm" | "md" | "lg" | "xl"
export type StyledTone = "info" | "success" | "warning" | "danger" | "brand"
/* Illustration bolgesinin hangi kompozisyonu cizecegini secen ayrimci. */
type IllustrationVariant = "spotlight" | "badge" | "circle" | "gradient" | "banner"
const panelWidth: Record<StyledSize, string> = {
sm: "max-w-sm",
md: "max-w-md",
lg: "max-w-lg",
xl: "max-w-xl",
}
const bodyPad: Record<StyledSize, string> = {
sm: "p-4",
md: "p-5",
lg: "p-6",
xl: "p-7",
}
/* Illustration bolgesinin yuksekligi size ile buyur. */
const artHeight: Record<StyledSize, string> = {
sm: "h-28",
md: "h-32",
lg: "h-36",
xl: "h-40",
}
/* Confirm button tone mapping - the tone token drives it directly. */
const confirmTone: Record<StyledTone, string> = {
info: "bg-info text-info-foreground hover:bg-info/90",
success: "bg-success text-success-foreground hover:bg-success/90",
warning: "bg-warning text-warning-foreground hover:bg-warning/90",
danger: "bg-danger text-danger-foreground hover:bg-danger/90",
brand: "bg-brand text-brand-foreground hover:bg-brand/90",
}
/* Illustration ikon rengi tone token'ina baglanir. */
const artIconColor: Record<StyledTone, string> = {
info: "text-info",
success: "text-success",
warning: "text-warning-soft-foreground",
danger: "text-danger",
brand: "text-brand",
}
const cancelBtn =
"inline-flex h-9 items-center justify-center rounded-md border border-border bg-transparent px-4 text-sm font-medium text-foreground transition-colors hover:bg-secondary focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 [&_svg]:size-4 [&_svg]:shrink-0 [&_i]:text-base [&_i]:leading-none"
const confirmBtn =
"inline-flex h-9 items-center justify-center gap-1.5 rounded-md px-4 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 [&_svg]:size-4 [&_svg]:shrink-0 [&_i]:text-base [&_i]:leading-none"
type StyledAlertPublicProps = {
size?: StyledSize
trigger?: React.ReactNode
title?: React.ReactNode
description?: React.ReactNode
confirmLabel?: string
cancelLabel?: string
onConfirm?: () => void
className?: string
open?: boolean
defaultOpen?: boolean
onOpenChange?: (o: boolean) => void
}
type IllustratedAlertCoreProps = StyledAlertPublicProps & {
tone: StyledTone
variant: IllustrationVariant
icon: React.ReactNode
}
/* Tone token'indan olusan spotlight radial degeri (buyuk ustteki isik havuzu). */
function spotlightBg(tone: StyledTone) {
const map: Record<StyledTone, string> = {
info: "bg-[radial-gradient(120%_140%_at_50%_120%,color-mix(in_oklab,var(--color-info)_30%,transparent),transparent_70%)]",
success:
"bg-[radial-gradient(120%_140%_at_50%_120%,color-mix(in_oklab,var(--color-success)_30%,transparent),transparent_70%)]",
warning:
"bg-[radial-gradient(120%_140%_at_50%_120%,color-mix(in_oklab,var(--color-warning)_32%,transparent),transparent_70%)]",
danger:
"bg-[radial-gradient(120%_140%_at_50%_120%,color-mix(in_oklab,var(--color-danger)_28%,transparent),transparent_70%)]",
brand:
"bg-[radial-gradient(120%_140%_at_50%_120%,color-mix(in_oklab,var(--color-brand)_28%,transparent),transparent_70%)]",
}
return map[tone]
}
/* Tone token'indan olusan gradient bant (soldan saga iki tonlu gecis). */
function gradientBg(tone: StyledTone) {
const map: Record<StyledTone, string> = {
info: "bg-[linear-gradient(120deg,color-mix(in_oklab,var(--color-info)_36%,transparent),color-mix(in_oklab,var(--color-info)_10%,transparent))]",
success:
"bg-[linear-gradient(120deg,color-mix(in_oklab,var(--color-success)_36%,transparent),color-mix(in_oklab,var(--color-success)_10%,transparent))]",
warning:
"bg-[linear-gradient(120deg,color-mix(in_oklab,var(--color-warning)_38%,transparent),color-mix(in_oklab,var(--color-warning)_10%,transparent))]",
danger:
"bg-[linear-gradient(120deg,color-mix(in_oklab,var(--color-danger)_34%,transparent),color-mix(in_oklab,var(--color-danger)_10%,transparent))]",
brand:
"bg-[linear-gradient(120deg,color-mix(in_oklab,var(--color-brand)_34%,transparent),color-mix(in_oklab,var(--color-brand)_10%,transparent))]",
}
return map[tone]
}
/* Tone token'indan olusan yumusak dolgu (rozet / banner arka plani). */
function softFill(tone: StyledTone) {
const map: Record<StyledTone, string> = {
info: "bg-[color-mix(in_oklab,var(--color-info)_16%,transparent)]",
success: "bg-[color-mix(in_oklab,var(--color-success)_16%,transparent)]",
warning: "bg-[color-mix(in_oklab,var(--color-warning)_18%,transparent)]",
danger: "bg-[color-mix(in_oklab,var(--color-danger)_16%,transparent)]",
brand: "bg-[color-mix(in_oklab,var(--color-brand)_16%,transparent)]",
}
return map[tone]
}
/* Tone token'indan olusan halka kenari (ic ice cemberler). */
function ringBorder(tone: StyledTone) {
const map: Record<StyledTone, string> = {
info: "border-[color-mix(in_oklab,var(--color-info)_35%,transparent)]",
success: "border-[color-mix(in_oklab,var(--color-success)_35%,transparent)]",
warning: "border-[color-mix(in_oklab,var(--color-warning)_38%,transparent)]",
danger: "border-[color-mix(in_oklab,var(--color-danger)_35%,transparent)]",
brand: "border-[color-mix(in_oklab,var(--color-brand)_35%,transparent)]",
}
return map[tone]
}
/* Illustration area: a decorative top region per variant plus the tone icon in the middle. The motion gating lives here, because the reduce information comes from the core. */
function Illustration({
variant,
tone,
icon,
size,
reduce,
}: {
variant: IllustrationVariant
tone: StyledTone
icon: React.ReactNode
size: StyledSize
reduce: boolean
}) {
const iconReveal = reduce
? { initial: false as const, animate: { opacity: 1, scale: 1 } }
: {
initial: { opacity: 0, scale: 0.7 },
animate: { opacity: 1, scale: 1 },
transition: { duration: 0.28, ease: "easeOut" as const, delay: 0.05 },
}
const iconWrap = cn(
"relative z-10 flex items-center justify-center [&_svg]:size-7 [&_svg]:shrink-0 [&_i]:text-3xl [&_i]:leading-none",
artIconColor[tone]
)
return (
<div
data-slot="styled-alert-dialog-illustration"
className={cn(
"relative flex w-full items-center justify-center overflow-hidden border-b border-border",
artHeight[size]
)}
>
{variant === "spotlight" ? (
<>
<div className={cn("absolute inset-0", spotlightBg(tone))} />
<motion.span
className={cn(
"flex size-14 items-center justify-center rounded-full border border-border bg-popover shadow-sm",
iconWrap
)}
{...iconReveal}
>
{icon}
</motion.span>
</>
) : null}
{variant === "badge" ? (
<motion.span
className={cn(
"flex size-20 items-center justify-center rounded-full",
softFill(tone),
iconWrap
)}
{...iconReveal}
>
{icon}
</motion.span>
) : null}
{variant === "circle" ? (
<div className="relative flex items-center justify-center">
<span
className={cn(
"absolute size-24 rounded-full border",
ringBorder(tone)
)}
/>
<span
className={cn(
"absolute size-16 rounded-full border",
ringBorder(tone)
)}
/>
<motion.span className={iconWrap} {...iconReveal}>
{icon}
</motion.span>
</div>
) : null}
{variant === "gradient" ? (
<>
<div className={cn("absolute inset-0", gradientBg(tone))} />
<motion.span
className={cn(
"flex size-14 items-center justify-center rounded-2xl border border-border bg-popover shadow-sm",
iconWrap
)}
{...iconReveal}
>
{icon}
</motion.span>
</>
) : null}
{variant === "banner" ? (
<>
<div className={cn("absolute inset-0", softFill(tone))} />
<div
className={cn(
"absolute inset-x-0 top-0 h-1.5",
confirmTone[tone].split(" ")[0]
)}
/>
<motion.span className={iconWrap} {...iconReveal}>
{icon}
</motion.span>
</>
) : null}
</div>
)
}
/* Shared modal core: state management, backdrop, panel, escape, focus, motion. Identical to Essentials; it additionally renders an Illustration area on top. */
function IllustratedAlertDialog({
size = "md",
trigger,
title,
description,
confirmLabel = "Confirm",
cancelLabel = "Cancel",
onConfirm,
className,
open,
defaultOpen,
onOpenChange,
tone,
variant,
icon,
}: IllustratedAlertCoreProps) {
const reduce = useReducedMotion() ?? false
const isControlled = open !== undefined
const [internalOpen, setInternalOpen] = React.useState(defaultOpen ?? false)
const actualOpen = isControlled ? open : internalOpen
const panelRef = React.useRef<HTMLDivElement>(null)
const setOpen = React.useCallback(
(next: boolean) => {
if (!isControlled) setInternalOpen(next)
onOpenChange?.(next)
},
[isControlled, onOpenChange]
)
React.useEffect(() => {
if (!actualOpen) return
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false)
}
document.addEventListener("keydown", onKey)
const raf = window.requestAnimationFrame(() => panelRef.current?.focus())
return () => {
document.removeEventListener("keydown", onKey)
window.cancelAnimationFrame(raf)
}
}, [actualOpen, setOpen])
const handleConfirm = () => {
onConfirm?.()
setOpen(false)
}
return (
<span data-slot="styled-alert-dialog" className="contents">
{/* ARIA durumu tetikleyicinin KENDISINDE. Once yoktu: ekran okuyucu
kullanicisi butonun bir dialog actigini ve acik olup olmadigini
HIC bilmiyordu. Canli AX taramasiyla bulundu (grep gostermemisti). */}
<span data-slot="styled-alert-dialog-trigger" className="inline-flex">
{React.isValidElement<React.ButtonHTMLAttributes<HTMLButtonElement>>(trigger) ? (
React.cloneElement(trigger, {
"aria-haspopup": "dialog",
"aria-expanded": actualOpen,
onClick: (event: React.MouseEvent<HTMLButtonElement>) => {
trigger.props.onClick?.(event)
setOpen(true)
},
})
) : (
<button
type="button"
aria-haspopup="dialog"
aria-expanded={actualOpen}
onClick={() => setOpen(true)}
className="inline-flex h-9 items-center justify-center rounded-md border border-border bg-secondary px-4 text-sm font-medium text-secondary-foreground transition-colors hover:bg-secondary/80 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
{trigger ?? "Open"}
</button>
)}
</span>
<AnimatePresence>
{actualOpen ? (
<>
<motion.div
data-slot="styled-alert-dialog-backdrop"
className="fixed inset-0 z-50 bg-[color-mix(in_oklab,var(--color-foreground)_45%,transparent)] supports-[backdrop-filter]:backdrop-blur-sm"
onClick={() => setOpen(false)}
initial={reduce ? false : { opacity: 0 }}
animate={{ opacity: 1 }}
exit={reduce ? { opacity: 1 } : { opacity: 0 }}
transition={{ duration: 0.2, ease: "easeOut" }}
/>
<div className="pointer-events-none fixed inset-0 z-50 flex items-center justify-center p-4">
<motion.div
ref={panelRef}
role="alertdialog"
aria-modal="true"
tabIndex={-1}
onClick={(e) => e.stopPropagation()}
className={cn(
"pointer-events-auto w-full overflow-hidden rounded-xl border border-border bg-popover text-popover-foreground shadow-lg outline-none",
panelWidth[size],
className
)}
initial={reduce ? false : { opacity: 0, scale: 0.96, y: 8 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={reduce ? { opacity: 1 } : { opacity: 0, scale: 0.96, y: 8 }}
transition={{ duration: 0.2, ease: "easeOut" }}
>
<Illustration
variant={variant}
tone={tone}
icon={icon}
size={size}
reduce={reduce}
/>
<div
data-tone={tone}
className={cn("flex flex-col items-center text-center", bodyPad[size])}
>
{title ? (
<h2
data-slot="styled-alert-dialog-title"
className="text-base font-semibold tracking-tight text-foreground"
>
{title}
</h2>
) : null}
{description ? (
<p
data-slot="styled-alert-dialog-description"
className="mt-1 text-sm text-muted-foreground"
>
{description}
</p>
) : null}
<div
data-slot="styled-alert-dialog-footer"
className="mt-5 flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-center"
>
<button
type="button"
data-slot="styled-alert-dialog-cancel"
className={cancelBtn}
onClick={() => setOpen(false)}
>
{cancelLabel}
</button>
<button
type="button"
data-slot="styled-alert-dialog-confirm"
className={cn(confirmBtn, confirmTone[tone])}
onClick={handleConfirm}
>
{confirmLabel}
</button>
</div>
</div>
</motion.div>
</div>
</>
) : null}
</AnimatePresence>
</span>
)
}
/* SpotlightAlert: ustte radial token spotlight isik havuzu, ortada yuzen daire
icinde tone ikon. */
export function SpotlightAlert({
title = "You are in the spotlight",
description = "Confirm to continue with this highlighted action.",
...props
}: StyledAlertPublicProps) {
return (
<IllustratedAlertDialog
variant="spotlight"
tone="info"
icon={<Info />}
title={title}
description={description}
{...props}
/>
)
}
/* BadgeAlert: ustte buyuk token yumusak-daire rozet, icinde tone ikon. */
export function BadgeAlert({
title = "Badge earned",
description = "Confirm to apply this change to your account.",
...props
}: StyledAlertPublicProps) {
return (
<IllustratedAlertDialog
variant="badge"
tone="success"
icon={<CheckCircle />}
title={title}
description={description}
{...props}
/>
)
}
/* CircleAlert: ic ice token halkalar ortasinda tone ikon. */
export function CircleAlert({
title = "Heads up",
description = "Please review this before you confirm the action.",
...props
}: StyledAlertPublicProps) {
return (
<IllustratedAlertDialog
variant="circle"
tone="warning"
icon={<AlertTriangle />}
title={title}
description={description}
{...props}
/>
)
}
/* GradientAlert: ustte tone gradient bant, ortada yuzen kutu icinde tone ikon. */
export function GradientAlert({
title = "One more step",
description = "Confirm to move forward with this action.",
...props
}: StyledAlertPublicProps) {
return (
<IllustratedAlertDialog
variant="gradient"
tone="brand"
icon={<Bell />}
title={title}
description={description}
{...props}
/>
)
}
/* BannerAlert: a full-width token banner on top (top line plus a soft fill) with a tone icon in the middle. */
export function BannerAlert({
title = "Delete this item?",
description = "This action cannot be undone once confirmed.",
confirmLabel = "Delete",
...props
}: StyledAlertPublicProps) {
return (
<IllustratedAlertDialog
variant="banner"
tone="danger"
icon={<AlertTriangle />}
title={title}
description={description}
confirmLabel={confirmLabel}
{...props}
/>
)
}Manual installs skip the @ai2/tokens theme, so add the token CSS from the theming guide or the tone colors will be missing.
Variations
5 takes on the same idea. Each is its own export, and every one accepts a size prop (sm, md, lg, xl) aligned to the base Button scale.
Spotlight
A radial token spotlight fills the top with a floating icon at its center.
import { SpotlightAlert } from "@/components/ui/alert-dialog-illustration"
<SpotlightAlert title="You are in the spotlight" description="Confirm to continue with this highlighted action." />Badge
A large soft-circle token badge holds the icon above the message.
import { BadgeAlert } from "@/components/ui/alert-dialog-illustration"
<BadgeAlert title="Badge earned" description="Confirm to apply this change to your account." />Circle
Concentric token rings frame the icon in the decorative header.
import { CircleAlert } from "@/components/ui/alert-dialog-illustration"
<CircleAlert title="Heads up" description="Please review this before you confirm the action." />Gradient
A two-tone token gradient band sits behind a floating icon tile.
import { GradientAlert } from "@/components/ui/alert-dialog-illustration"
<GradientAlert title="One more step" description="Confirm to move forward with this action." />Banner
A full-width token banner with a top accent line and centered icon.
import { BannerAlert } from "@/components/ui/alert-dialog-illustration"
<BannerAlert title="Delete this item?" description="This action cannot be undone once confirmed." />ai2 Illustrated alert dialogs: 5 styled variations on the token system
The ai2 Illustrated alert dialogs are a set of 5 decorative button variations from the styled layer of the @ai2 design system, built around a large decorative top region built purely from semantic tokens. They are free and MIT licensed, and every color comes from a semantic token, so they theme with the rest of ai2 in light and dark.
Motion runs on framer-motion: framer-motion animates the panel enter and exit, the backdrop fades, and the illustration icon reveals with a scale. The styled layer is opt-in, so the dependency only lands if you use it; the base components stay lean. Under reduced motion, the transforms are skipped and the dialog and icon appear instantly.
What is in the ai2 Illustrated alert dialogs?
5 exports in one file: Spotlight, Badge, Circle, Gradient and Banner. Each renders a native button and takes a size prop (sm, md, lg, xl) aligned to the base Button. They are separate from the base Button on purpose: the base keeps its clean variant, tone and size axes, while the styled layer carries the effects.
You own the file. Copy the one category file and you have all 5 variations, with no runtime dependency on ai2 itself.
Why use it
- On-system by construction: Every color resolves to an ai2 semantic token, so the buttons follow your theme in light and dark with no extra work.
- Effect without the sprawl: The decorations live in a dedicated styled file, so the base Button keeps its clean, predictable API.
- Accessible and honest: Each renders a real button element, keeps a visible focus ring, and respects prefers-reduced-motion.
Features
- Token-driven color: No hardcoded hex or oklch; the look recolors with your theme tokens.
- framer-motion: framer-motion animates the panel enter and exit, the backdrop fades, and the illustration icon reveals with a scale.
- Reduced-motion aware: Under prefers-reduced-motion, the transforms are skipped and the dialog and icon appear instantly.
- Size aligned to the base: Every variation takes sm, md, lg and xl matching the base Button height scale, so styled and base buttons line up in a row.
Production tips
- Use it for emphasis, not everywhere: Styled buttons draw the eye. Reserve them for the one action you want people to take on a screen, and use the base Button for the rest.
- Keep labels as verbs: The decoration adds weight, so a clear action label keeps the button scannable.
- Pick one variation per surface: The variations share a family; using two different ones in the same view competes for attention.
Works with the rest of ai2
The Illustrated alert dialogs sit alongside the base Button and the rest of the @ai2 registry. They share the same token file, so a styled action next to a base button or a badge stays visually consistent in both modes.