Dialog
A modal dialog whose body scrolls between a sticky header and footer, while the page behind it stays still.
Dialog
client - size not measured yetA modal dialog whose body scrolls between a sticky header and footer, while the page behind it stays still.
Try it
The code
import { Button } from 'topclassui/button'
import {
<DialogRoot>
<DialogTrigger render={<Button>Publish</Button>} />
<DialogPopup>
<DialogHeader>
<DialogTitle>Publish changes</DialogTitle>
<DialogDescription>Your changes go live right away.</DialogDescription>
</DialogHeader>
<DialogBody>11 files changed since the last publish.</DialogBody>
<DialogFooter>
<DialogClose render={<Button variant="outline">Cancel</Button>} />
<DialogClose render={<Button>Publish now</Button>} />
</DialogFooter>
</DialogPopup>
</DialogRoot>Wrap the parts in a form with the class contents, and a Server Action submits from the footer with no client handler.
Every state
5 statesImport from the package
import { DialogRoot, DialogBody, DialogClose, DialogDescription, DialogFooter, DialogHeader, DialogPopup, DialogTitle, DialogTrigger } from 'topclassui/dialog'Install once:npm i topclassui@beta zod
Examples
- For a task or a question the page waits for: a form, a confirm, a text to accept.
- Nine flat parts and no bare
Dialog:DialogRoot,DialogTrigger,DialogPopup,DialogHeader,DialogTitle,DialogDescription,DialogBody,DialogFooterandDialogClose. DialogBodyscrolls and the header and the footer stay put. On a very short screen the whole popup scrolls instead.sizeonDialogPopupis the widest it grows: 320, 360, 400, 512 or 640 px.customsets no width.- For a confirm,
disablePointerDismissalonDialogRoot: a press outside no longer closes it. - From a server page pass text, booleans, elements and a Server Action.
onOpenChangeand any other function need a'use client'file.
import { Button } from 'topclassui/button'
import {
DialogBody, DialogClose, DialogDescription, DialogFooter, DialogHeader, DialogPopup, DialogRoot, DialogTitle,
DialogTrigger,
} from 'topclassui/dialog'
/** The docs sample without its form: a header with the built-in x, a body and a footer.
* Each label sits inside the Button passed to render, so a server render keeps it whole. */
export default function Demo() {
return (
<DialogRoot>
<DialogTrigger render={<Button>Publish</Button>} />
<DialogPopup>
<DialogHeader>
<DialogTitle>Publish changes</DialogTitle>
<DialogDescription>Your changes go live right away.</DialogDescription>
</DialogHeader>
<DialogBody>11 files changed since the last publish.</DialogBody>
<DialogFooter>
<DialogClose render={<Button variant="outline">Cancel</Button>} />
<DialogClose render={<Button>Publish now</Button>} />
</DialogFooter>
</DialogPopup>
</DialogRoot>
)
}
import { Button } from 'topclassui/button'
import {
DialogBody, DialogClose, DialogDescription, DialogFooter, DialogHeader, DialogPopup, DialogRoot, DialogTitle,
DialogTrigger,
} from 'topclassui/dialog'
/** The drawn confirm: a press outside does not close it, so the answer is one of the two
* buttons, the x or Escape. The consequence is the description, so a screen reader reads it
* with the title when the dialog opens. No AlertDialog exists; this is a Dialog with one prop. */
export default function Demo() {
return (
<DialogRoot disablePointerDismissal>
<DialogTrigger render={<Button variant="outline" tone="danger">Delete project</Button>} />
<DialogPopup>
<DialogHeader>
<DialogTitle>Delete this project?</DialogTitle>
</DialogHeader>
<DialogBody>
<DialogDescription>This removes the project and every file in it. This cannot be undone.</DialogDescription>
</DialogBody>
<DialogFooter>
<DialogClose render={<Button variant="outline" size="sm">Cancel</Button>} />
<DialogClose render={<Button tone="danger" size="sm">Delete</Button>} />
</DialogFooter>
</DialogPopup>
</DialogRoot>
)
}
import { Button } from 'topclassui/button'
import { Input } from 'topclassui/input'
import {
DialogBody, DialogClose, DialogFooter, DialogHeader, DialogPopup, DialogRoot, DialogTitle, DialogTrigger,
} from 'topclassui/dialog'
/** One form with display contents wraps the three bars, so the footer's submit button belongs
* to it and a Server Action needs no client handler. autoFocus puts the first focus in the field. */
export default function Demo() {
return (
<DialogRoot>
<DialogTrigger render={<Button variant="outline">Rename</Button>} />
<DialogPopup>
<form className="contents">
<DialogHeader>
<DialogTitle>Rename this project</DialogTitle>
</DialogHeader>
<DialogBody className="grid gap-1">
<label htmlFor="demo-project-name" className="font-medium text-body">Name</label>
<Input id="demo-project-name" name="name" defaultValue="Website redesign" required fullWidth autoFocus />
</DialogBody>
<DialogFooter>
<DialogClose render={<Button variant="outline">Cancel</Button>} />
<Button type="submit">Save</Button>
</DialogFooter>
</form>
</DialogPopup>
</DialogRoot>
)
}
'use client'
import { useActionState, useState } from 'react'
import { Button } from 'topclassui/button'
import { Input } from 'topclassui/input'
import {
DialogBody, DialogClose, DialogFooter, DialogHeader, DialogPopup, DialogRoot, DialogTitle, DialogTrigger,
} from 'topclassui/dialog'
interface Attempt { name: string, error: string | null }
/** The one field of the form, as text: a file field would hand over a File. */
function nameIn(data: FormData): string {
const name = data.get('name')
return typeof name === 'string' ? name : ''
}
/** Stands in for a Server Action: it answers with an error message, or with none. */
async function renameProject(data: FormData): Promise<string | null> {
await new Promise((resolve) => { setTimeout(resolve, 100) })
return nameIn(data).trim().toLowerCase() === 'untitled' ? 'That name is taken.' : null
}
/** The form holds its own attempt, so it starts clean each time the dialog opens. React
* empties a form after every action: the name comes back through the state, with the error. */
function RenameForm({ onSaved }: { onSaved: () => void }) {
const [attempt, save, saving] = useActionState(async (_previous: Attempt, data: FormData): Promise<Attempt> => {
const error = await renameProject(data)
if (error === null) onSaved()
return { name: nameIn(data), error }
}, { name: 'Website redesign', error: null })
return (
<form action={save} className="contents">
<DialogHeader>
<DialogTitle>Rename this project</DialogTitle>
</DialogHeader>
<DialogBody className="grid gap-1">
<label htmlFor="demo-rename" className="font-medium text-body">Name</label>
<Input
id="demo-rename"
name="name"
defaultValue={attempt.name}
invalid={attempt.error !== null}
aria-describedby={attempt.error === null ? undefined : 'demo-rename-error'}
required
fullWidth
autoFocus
/>
{attempt.error === null ? null : <p id="demo-rename-error" className="text-danger-on-soft">{attempt.error}</p>}
</DialogBody>
<DialogFooter>
<DialogClose render={<Button variant="outline">Cancel</Button>} />
<Button type="submit" loading={saving}>Save</Button>
</DialogFooter>
</form>
)
}
/** Closes when the action succeeds and stays open when it fails. `open` and `onOpenChange`
* need a client file: a Server Component page cannot pass a function. Try the name Untitled. */
export default function Demo() {
const [open, setOpen] = useState(false)
return (
<DialogRoot open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button variant="outline">Rename and close</Button>} />
<DialogPopup>
<RenameForm onSaved={() => { setOpen(false) }} />
</DialogPopup>
</DialogRoot>
)
}
import { Button } from 'topclassui/button'
import {
DialogBody, DialogClose, DialogDescription, DialogFooter, DialogHeader, DialogPopup, DialogRoot, DialogTitle,
DialogTrigger,
} from 'topclassui/dialog'
const CLAUSES = Array.from({ length: 40 }, (_, index) => index + 1)
/** A body taller than any screen: it scrolls inside the popup, and the header and the
* footer keep their places above and below it (R-10.5-1). */
export default function Demo() {
return (
<DialogRoot>
<DialogTrigger render={<Button variant="outline">Read the terms</Button>} />
<DialogPopup>
<DialogHeader>
<DialogTitle>Terms of service</DialogTitle>
<DialogDescription>Forty clauses. Scroll to read them all.</DialogDescription>
</DialogHeader>
<DialogBody className="grid gap-3">
{CLAUSES.map((clause) => (
<p key={clause}>
Clause {clause}. You keep the rights to what you publish, and you let us store it and show it
to the people you share it with.
</p>
))}
</DialogBody>
<DialogFooter>
<DialogClose render={<Button variant="outline">Decline</Button>} />
<DialogClose render={<Button>Accept</Button>} />
</DialogFooter>
</DialogPopup>
</DialogRoot>
)
}
import { Button } from 'topclassui/button'
import {
DialogBody, DialogClose, DialogDescription, DialogFooter, DialogHeader, DialogPopup, DialogRoot, DialogTitle,
DialogTrigger,
} from 'topclassui/dialog'
const TITLE = 'Move the quarterly report, its eleven attachments and every comment thread to the shared archive of the finance team'
const WORD = 'a'.repeat(300)
const FILE = 'quarterly-report-final-v2-reviewed-by-legal-and-finance-and-signed-off-by-the-board.pdf'
/** Hostile content: a title of many lines, a word of 300 characters and three long actions.
* The title wraps beside the x, the word breaks inside the body, and a long action wraps onto
* two lines. On a small phone the body scrolls, and is a tab stop for as long as it does. */
export default function Demo() {
return (
<DialogRoot>
<DialogTrigger render={<Button variant="outline">Move to the archive</Button>} />
<DialogPopup>
<DialogHeader>
<DialogTitle>{TITLE}</DialogTitle>
<DialogDescription>{FILE}</DialogDescription>
</DialogHeader>
<DialogBody>{WORD}</DialogBody>
<DialogFooter>
<DialogClose render={<Button variant="outline">Keep it where it is</Button>} />
<DialogClose render={<Button variant="outline">Move a copy and keep the original</Button>} />
<DialogClose render={<Button>Move everything to the archive</Button>} />
</DialogFooter>
</DialogPopup>
</DialogRoot>
)
}
import { Button } from 'topclassui/button'
import {
DialogBody, DialogClose, DialogFooter, DialogHeader, DialogPopup, DialogRoot, DialogTitle, DialogTrigger,
} from 'topclassui/dialog'
import type { DialogPopupProps } from 'topclassui/dialog'
const WIDTHS: Array<[NonNullable<DialogPopupProps['size']>, string]> = [
['xs', '320 px'],
['sm', '360 px'],
['md', '400 px'],
['lg', '512 px'],
['xl', '640 px'],
]
/** The five widths, and custom, which sets none: its className gives the popup one.
* Each is the widest the popup grows; on a narrower screen every one fills the viewport. */
export default function Demo() {
return (
<div className="flex flex-wrap items-center gap-3">
{WIDTHS.map(([size, width]) => (
<DialogRoot key={size}>
<DialogTrigger render={<Button variant="outline">{size}</Button>} />
<DialogPopup size={size}>
<DialogHeader>
<DialogTitle>Size {size}</DialogTitle>
</DialogHeader>
<DialogBody>This dialog grows to {width} and no wider.</DialogBody>
<DialogFooter>
<DialogClose render={<Button>Done</Button>} />
</DialogFooter>
</DialogPopup>
</DialogRoot>
))}
<DialogRoot>
<DialogTrigger render={<Button variant="outline">custom</Button>} />
<DialogPopup size="custom" className="max-w-3xl">
<DialogHeader>
<DialogTitle>Size custom</DialogTitle>
</DialogHeader>
<DialogBody>This dialog takes its width from the className it was given.</DialogBody>
<DialogFooter>
<DialogClose render={<Button>Done</Button>} />
</DialogFooter>
</DialogPopup>
</DialogRoot>
</div>
)
}
import { Button } from 'topclassui/button'
import {
DialogBody, DialogClose, DialogDescription, DialogFooter, DialogHeader, DialogPopup, DialogRoot, DialogTitle,
DialogTrigger,
} from 'topclassui/dialog'
/** closeButton={false} leaves the x out, so the footer must hold a DialogClose: a touch
* screen reader has no Escape key and needs a control that closes the dialog. */
export default function Demo() {
return (
<DialogRoot>
<DialogTrigger render={<Button variant="soft">What is new</Button>} />
<DialogPopup size="sm">
<DialogHeader closeButton={false}>
<DialogTitle>Version 2.4</DialogTitle>
<DialogDescription>Released this week.</DialogDescription>
</DialogHeader>
<DialogBody>Tables can now pin their first column, and every menu takes a link.</DialogBody>
<DialogFooter>
<DialogClose render={<Button>Got it</Button>} />
</DialogFooter>
</DialogPopup>
</DialogRoot>
)
}
import { Button } from 'topclassui/button'
import {
DialogBody, DialogClose, DialogFooter, DialogHeader, DialogPopup, DialogRoot, DialogTitle, DialogTrigger,
} from 'topclassui/dialog'
/** modal={false}: no scrim, and the page behind stays in use. It scrolls, its buttons take a
* press, and a screen reader still reads it. Focus is not held: Tab past the last control,
* or a press anywhere outside, closes the dialog. */
export default function Demo() {
return (
<div className="flex flex-wrap items-center gap-3">
<DialogRoot modal={false}>
<DialogTrigger render={<Button variant="outline">Find and replace</Button>} />
<DialogPopup size="xs">
<DialogHeader>
<DialogTitle>Find and replace</DialogTitle>
</DialogHeader>
<DialogBody>The page stays live behind this panel, so you can keep working in it.</DialogBody>
<DialogFooter>
<DialogClose render={<Button>Done</Button>} />
</DialogFooter>
</DialogPopup>
</DialogRoot>
<Button variant="ghost">Behind the panel</Button>
</div>
)
}
import { Button } from 'topclassui/button'
import {
DialogBody, DialogClose, DialogFooter, DialogHeader, DialogPopup, DialogRoot, DialogTitle, DialogTrigger,
} from 'topclassui/dialog'
/** modal="trap-focus": no scrim, and the pointer keeps the page behind: it scrolls and its
* buttons take a press. The keyboard and a screen reader stay in the dialog: Tab goes round
* inside it and the page is hidden from them until it closes. */
export default function Demo() {
return (
<div className="flex flex-wrap items-center gap-3">
<DialogRoot modal="trap-focus">
<DialogTrigger render={<Button variant="outline">Inspect</Button>} />
<DialogPopup size="xs">
<DialogHeader>
<DialogTitle>Inspector</DialogTitle>
</DialogHeader>
<DialogBody>Point at anything on the page behind. The keyboard stays in this panel.</DialogBody>
<DialogFooter>
<DialogClose render={<Button>Done</Button>} />
</DialogFooter>
</DialogPopup>
</DialogRoot>
<Button variant="ghost">Behind the panel</Button>
</div>
)
}
import { copy } from 'topclassui/icons/copy'
import { Button } from 'topclassui/button'
import { Input } from 'topclassui/input'
import { Svg } from 'topclassui/svg'
import { TooltipPopup, TooltipRoot, TooltipTrigger } from 'topclassui/tooltip'
import {
DialogBody, DialogClose, DialogFooter, DialogHeader, DialogPopup, DialogRoot, DialogTitle, DialogTrigger,
} from 'topclassui/dialog'
/** A tooltip inside a dialog. One Escape closes one thing: while the tooltip is open, by
* hover or by focus, the key closes it and the dialog stays; the next one closes the dialog. */
export default function Demo() {
return (
<DialogRoot>
<DialogTrigger render={<Button variant="outline">Share</Button>} />
<DialogPopup>
<DialogHeader>
<DialogTitle>Share this project</DialogTitle>
</DialogHeader>
<DialogBody className="flex items-center gap-2">
<Input aria-label="Link" defaultValue="https://example.com/p/website-redesign" readOnly fullWidth />
<TooltipRoot>
<TooltipTrigger
render={
<Button variant="outline" aria-label="Copy link">
<Svg icon={copy} />
</Button>
}
/>
<TooltipPopup>Copy link</TooltipPopup>
</TooltipRoot>
</DialogBody>
<DialogFooter>
<DialogClose render={<Button>Done</Button>} />
</DialogFooter>
</DialogPopup>
</DialogRoot>
)
}
import { TopClassProvider } from 'topclassui/provider'
import { Button } from 'topclassui/button'
import {
DialogBody, DialogClose, DialogDescription, DialogFooter, DialogHeader, DialogPopup, DialogRoot, DialogTitle,
DialogTrigger,
} from 'topclassui/dialog'
/** Arabic, inside TopClassProvider as the root layout would mount it. The popup is portaled
* into the body, so it takes its direction and its language from the provider: the x is on
* the left, the text is right-aligned and the actions end at the left, on an English page too. */
export default function Demo() {
return (
<TopClassProvider direction="rtl" locale="ar">
<div dir="rtl" lang="ar">
<DialogRoot>
<DialogTrigger render={<Button>نشر</Button>} />
<DialogPopup>
<DialogHeader>
<DialogTitle>نشر التغييرات</DialogTitle>
<DialogDescription>تظهر تغييراتك للجميع فورًا.</DialogDescription>
</DialogHeader>
<DialogBody>تغيّر 11 ملفًا منذ آخر نشر.</DialogBody>
<DialogFooter>
<DialogClose render={<Button variant="outline">إلغاء</Button>} />
<DialogClose render={<Button>انشر الآن</Button>} />
</DialogFooter>
</DialogPopup>
</DialogRoot>
</div>
</TopClassProvider>
)
}
Props
DialogRoot
DialogBody
No props of its own: it takes the attributes of the element it renders.
DialogClose
No props of its own: it takes the attributes of the element it renders.
DialogDescription
No props of its own: it takes the attributes of the element it renders.
DialogFooter
No props of its own: it takes the attributes of the element it renders.
DialogHeader
DialogPopup
DialogTitle
No props of its own: it takes the attributes of the element it renders.
DialogTrigger
No props of its own: it takes the attributes of the element it renders.
Keyboard and accessibility
DialogTitlenames the dialog. With no title, passaria-labeltoDialogPopup.- Remove the x with
closeButton={false}only when aDialogClosesits in the footer: a touch screen reader has no Escape key. - While
DialogBodyscrolls it is a tab stop, so the keyboard reaches a long text with no control in it.
Right to left
The x moves to the left, the text aligns right and the actions end at the left. The popup takes its direction from the nearest TopClassProvider.
Server and client
A client component: it renders on the server and hydrates in the browser.
Files: index.tsx, client.tsx.
On the chip: client - size not measured yet.