Header dialogs
Five modal dialogs whose header region varies: accent, icon, gradient, sticky and divider. Each is self-contained (no radix), sized, token-driven, animates with a plain center fade and scale, 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/dialog-headerDependencies, the @ai2/tokens theme and the component file are installed together.
Install dependencies
npm install motion lucide-reactCopy the source
components/ui/dialog-header.tsx"use client"
import * as React from "react"
import { Info, X } from "lucide-react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { cn } from "@/lib/utils"
/* Header-themed dialog family: 5 modals that all carry the same plain center
fade+scale animation; the difference is ONLY in how the header region is
presented. Each export is a complete dialog: internal open/closed state
(uncontrolled defaultOpen or controlled open/onOpenChange), NO radix or portal -
a self-contained fixed backdrop + fixed panel. It closes on a backdrop click,
Escape or the close button. The panel takes focus on open. AnimatePresence
handles enter/exit; no transform under reduced motion (only a fade). Color comes
ONLY from tokens, via alpha color-mix. */
export type StyledSize = "sm" | "md" | "lg" | "xl"
const panelSize: Record<StyledSize, string> = {
sm: "max-w-sm",
md: "max-w-md",
lg: "max-w-lg",
xl: "max-w-2xl",
}
interface DialogProps {
size?: StyledSize
trigger?: React.ReactNode
title?: React.ReactNode
children?: React.ReactNode
className?: string
open?: boolean
defaultOpen?: boolean
onOpenChange?: (o: boolean) => void
}
const backdropClass =
"fixed inset-0 z-50 flex items-center justify-center p-4 bg-[color-mix(in_oklab,var(--color-foreground)_50%,transparent)]"
const panelBase =
"relative w-full rounded-xl border border-border bg-popover text-popover-foreground shadow-lg outline-none"
const closeBtn =
"absolute right-3 top-3 z-20 inline-flex size-8 items-center justify-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-[color-mix(in_oklab,var(--color-foreground)_8%,transparent)] hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 [&_svg]:size-4 [&_svg]:shrink-0 [&_i]:text-base [&_i]:leading-none"
const triggerBtn =
"inline-flex h-9 shrink-0 select-none items-center justify-center gap-2 whitespace-nowrap rounded-lg border border-border bg-secondary px-4 text-sm font-medium text-secondary-foreground outline-none transition-colors hover:bg-[color-mix(in_oklab,var(--color-foreground)_6%,transparent)] focus-visible:ring-[3px] focus-visible:ring-ring/50"
/* Tum ailenin ortak animasyonu: sade center fade + scale. */
const centerMotion = {
initial: { opacity: 0, scale: 0.94 },
animate: { opacity: 1, scale: 1 },
exit: { opacity: 0, scale: 0.94 },
transition: { type: "spring", stiffness: 320, damping: 26 },
} as const
/* Controlled/uncontrolled acik durum yonetimi. */
function useDialogState(props: DialogProps) {
const { open, defaultOpen, onOpenChange } = props
const isControlled = open !== undefined
const [internal, setInternal] = React.useState(defaultOpen ?? false)
const isOpen = isControlled ? open : internal
const setOpen = React.useCallback(
(next: boolean) => {
if (!isControlled) setInternal(next)
onOpenChange?.(next)
},
[isControlled, onOpenChange]
)
return { isOpen, setOpen }
}
/* Shared shell: trigger plus an AnimatePresence backdrop plus panel. renderHeader draws the header area per variant; when scrollable is true the panel content scrolls and the header can stay stuck (in the variant that wants sticky). */
function DialogShell({
props,
renderHeader,
scrollable,
}: {
props: DialogProps
renderHeader: (title: React.ReactNode) => React.ReactNode
scrollable?: boolean
}) {
const { size = "md", trigger, title, children, className } = props
const { isOpen, setOpen } = useDialogState(props)
const reduce = useReducedMotion()
const panelRef = React.useRef<HTMLDivElement>(null)
React.useEffect(() => {
if (!isOpen) return
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false)
}
window.addEventListener("keydown", onKey)
const id = window.requestAnimationFrame(() => panelRef.current?.focus())
return () => {
window.removeEventListener("keydown", onKey)
window.cancelAnimationFrame(id)
}
}, [isOpen, setOpen])
const fade = { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 } }
const motionProps = reduce ? fade : centerMotion
const header = title ? renderHeader(title) : null
const body = <div className="px-6 py-5 text-sm text-muted-foreground">{children}</div>
return (
<>
{/* 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-dialog-trigger" className="inline-flex">
{React.isValidElement<React.ButtonHTMLAttributes<HTMLButtonElement>>(trigger) ? (
React.cloneElement(trigger, {
"aria-haspopup": "dialog",
"aria-expanded": isOpen,
onClick: (event: React.MouseEvent<HTMLButtonElement>) => {
trigger.props.onClick?.(event)
setOpen(true)
},
})
) : (
<button
type="button"
aria-haspopup="dialog"
aria-expanded={isOpen}
onClick={() => setOpen(true)}
className={triggerBtn}
>
{trigger ?? "Open dialog"}
</button>
)}
</span>
<AnimatePresence>
{isOpen ? (
<motion.div
data-slot="styled-dialog"
className={backdropClass}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2, ease: "easeOut" }}
onClick={() => setOpen(false)}
>
<motion.div
ref={panelRef}
role="dialog"
aria-modal="true"
tabIndex={-1}
className={cn(
panelBase,
panelSize[size],
scrollable && "flex max-h-[80vh] flex-col overflow-hidden",
className
)}
initial={motionProps.initial}
animate={motionProps.animate}
exit={motionProps.exit}
transition={reduce ? { duration: 0.2, ease: "easeOut" } : centerMotion.transition}
onClick={(e) => e.stopPropagation()}
>
<button
type="button"
aria-label="Close"
className={closeBtn}
onClick={() => setOpen(false)}
>
<X />
</button>
{scrollable ? (
<div className="min-h-0 flex-1 overflow-y-auto">
{header}
{body}
</div>
) : (
<>
{header}
{body}
</>
)}
</motion.div>
</motion.div>
) : null}
</AnimatePresence>
</>
)
}
/* Accent: baslik solunda dikey primary accent cubugu. */
export function AccentHeaderDialog(props: DialogProps) {
return (
<DialogShell
props={props}
renderHeader={(title) => (
<div className="flex items-center gap-3 border-b border-border px-6 py-4 pr-12 text-base font-semibold">
<span className="h-5 w-1 shrink-0 rounded-full bg-primary" aria-hidden />
{title}
</div>
)}
/>
)
}
/* Icon: baslik solunda token soft-daire icinde lucide ikon + baslik. */
export function IconHeaderDialog(props: DialogProps) {
return (
<DialogShell
props={props}
renderHeader={(title) => (
<div className="flex items-center gap-3 border-b border-border px-6 py-4 pr-12 text-base font-semibold">
<span
className="flex size-8 shrink-0 items-center justify-center rounded-full bg-[color-mix(in_oklab,var(--color-primary)_14%,transparent)] text-primary [&_i]:text-base [&_i]:leading-none [&_svg]:size-4 [&_svg]:shrink-0"
aria-hidden
>
<Info />
</span>
{title}
</div>
)}
/>
)
}
/* Gradient: baslik bandi arka plani token'lardan gradient, foreground metin. */
export function GradientHeaderDialog(props: DialogProps) {
return (
<DialogShell
props={props}
renderHeader={(title) => (
<div className="rounded-t-xl border-b border-border bg-[linear-gradient(135deg,color-mix(in_oklab,var(--color-primary)_92%,transparent),color-mix(in_oklab,var(--color-primary)_55%,transparent))] px-6 py-4 pr-12 text-base font-semibold text-primary-foreground">
{title}
</div>
)}
/>
)
}
/* Sticky: long scrollable content; the header stays sticky top-0 and flush with the token background. */
export function StickyHeaderDialog(props: DialogProps) {
return (
<DialogShell
props={props}
scrollable
renderHeader={(title) => (
<div className="sticky top-0 z-10 border-b border-border bg-popover px-6 py-4 pr-12 text-base font-semibold">
{title}
</div>
)}
/>
)
}
/* Divider: baslik ustunde kucuk eyebrow etiketi, altinda kalin token ayrac. */
export function DividerHeaderDialog(props: DialogProps) {
return (
<DialogShell
props={props}
renderHeader={(title) => (
<div className="flex flex-col gap-1 border-b-2 border-border px-6 py-4 pr-12">
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Section
</span>
<span className="text-base font-semibold">{title}</span>
</div>
)}
/>
)
}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.
Accent
A vertical primary accent bar left of the title.
import { AccentHeaderDialog } from "@/components/ui/dialog-header"
<AccentHeaderDialog title="Dialog title">A self-contained modal dialog. Click the backdrop or press Escape to close.</AccentHeaderDialog>Icon
A token soft circle with an icon left of the title.
import { IconHeaderDialog } from "@/components/ui/dialog-header"
<IconHeaderDialog title="Dialog title">A self-contained modal dialog. Click the backdrop or press Escape to close.</IconHeaderDialog>Gradient
A token gradient header band with foreground text.
import { GradientHeaderDialog } from "@/components/ui/dialog-header"
<GradientHeaderDialog title="Dialog title">A self-contained modal dialog. Click the backdrop or press Escape to close.</GradientHeaderDialog>Sticky
A scrollable panel whose header sticks to the top.
import { StickyHeaderDialog } from "@/components/ui/dialog-header"
<StickyHeaderDialog title="Dialog title">A self-contained modal dialog with a header that stays pinned while the body scrolls. Click the backdrop or press Escape to close. This example carries extra content so the scroll region becomes visible. Use a sticky header when the dialog holds a long form, a terms-of-service block, a changelog, or a settings panel that runs past the viewport. The title, close button, and any primary action stay reachable at the top no matter how far the user scrolls, so they never lose context or the way out. Keep the header compact: a title, an optional short subtitle, and the close control. Push everything else into the scrollable body. On small screens the panel caps its height and the body takes the remaining space, so the header never pushes the close button off screen. The header stays fixed at the top of the panel the whole time you scroll through this content.</StickyHeaderDialog>Divider
An eyebrow label above a thick token divider.
import { DividerHeaderDialog } from "@/components/ui/dialog-header"
<DividerHeaderDialog title="Dialog title">A self-contained modal dialog. Click the backdrop or press Escape to close.</DividerHeaderDialog>ai2 Header dialogs: 5 styled variations on the token system
The ai2 Header dialogs are a set of 5 decorative button variations from the styled layer of the @ai2 design system, built around dialogs whose header region varies. 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 runs a plain center fade and scale; the backdrop fades. 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 fades or shows instantly.
What is in the ai2 Header dialogs?
5 exports in one file: Accent, Icon, Gradient, Sticky and Divider. 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 runs a plain center fade and scale; the backdrop fades.
- Reduced-motion aware: Under prefers-reduced-motion, the transforms are skipped and the dialog fades or shows 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 Header 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.