A modal window over the page that keeps focus inside until it closes.
View codeHide code
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogTitle,
DialogTrigger
} from '@syncclouds/ui'
export function DialogDemo() {
return (
<Dialog>
<DialogTrigger>Delete project</DialogTrigger>
<DialogContent>
<DialogTitle>Delete this project?</DialogTitle>
<DialogDescription>
This removes the project and its deploy history. You can't undo it.
</DialogDescription>
<div className="mt-6 flex justify-end gap-3">
<DialogClose autoFocus>Cancel</DialogClose>
<DialogClose data-tone="danger">Delete</DialogClose>
</div>
</DialogContent>
</Dialog>
)
}Installation
pnpm add @syncclouds/uinpm install @syncclouds/uiyarn add @syncclouds/uibun add @syncclouds/uipnpm dlx shadcn@latest add https://ui.syncclouds.com/r/dialog.jsonnpx shadcn@latest add https://ui.syncclouds.com/r/dialog.jsonyarn dlx shadcn@latest add https://ui.syncclouds.com/r/dialog.jsonbunx --bun shadcn@latest add https://ui.syncclouds.com/r/dialog.jsonThe CLI copies 4 files into your ui folder.
1. Copy these files into one folder
dialog.tsxShowHide
'use client'
import {
createContext,
use,
useEffect,
useId,
useRef,
type ComponentPropsWithRef,
type MouseEvent,
type RefObject
} from 'react'
import { Slot } from './slot'
import { useComposedRefs } from './sui-refs'
import { useControllableState } from './sui-use-controllable-state'
interface DialogContextValue {
open: boolean
setOpen: (open: boolean) => void
triggerRef: RefObject<HTMLButtonElement | null>
contentId: string
titleId: string
descriptionId: string
}
const DialogContext = createContext<DialogContextValue | null>(null)
const useDialog = (part: string) => {
const context = use(DialogContext)
if (!context) throw new Error(`<${part}> must be used inside <Dialog>`)
return context
}
export interface DialogProps {
open?: boolean
defaultOpen?: boolean
onOpenChange?: (open: boolean) => void
children?: React.ReactNode
}
/**
* A modal dialog (APG Dialog). Built on the native <dialog> element with
* showModal(), so the browser keeps focus inside, makes the page inert and
* closes it on Esc. Focus returns to the trigger on close. Give it a
* <DialogTitle> and a <DialogDescription>; they name and describe it.
*/
export function Dialog({
open,
defaultOpen = false,
onOpenChange,
children
}: DialogProps) {
const [isOpen, setOpen] = useControllableState({
prop: open,
defaultProp: defaultOpen,
onChange: onOpenChange
})
const triggerRef = useRef<HTMLButtonElement | null>(null)
const id = useId()
return (
<DialogContext
value={{
open: isOpen,
setOpen,
triggerRef,
contentId: `${id}-content`,
titleId: `${id}-title`,
descriptionId: `${id}-description`
}}
>
{children}
</DialogContext>
)
}
export function DialogTrigger({
asChild,
onClick,
ref,
...props
}: ComponentPropsWithRef<'button'> & { asChild?: boolean }) {
const { open, setOpen, triggerRef, contentId } = useDialog('DialogTrigger')
const composedRef = useComposedRefs(ref, triggerRef)
const Comp = asChild ? Slot : 'button'
return (
<Comp
type={asChild ? undefined : 'button'}
{...props}
ref={composedRef}
aria-haspopup="dialog"
aria-expanded={open}
aria-controls={contentId}
data-scope="dialog"
data-part="trigger"
data-state={open ? 'open' : 'closed'}
onClick={(event: MouseEvent<HTMLButtonElement>) => {
onClick?.(event)
if (!event.defaultPrevented) setOpen(true)
}}
/>
)
}
export function DialogContent({
onClick,
ref,
...props
}: ComponentPropsWithRef<'dialog'>) {
const { open, setOpen, triggerRef, contentId, titleId, descriptionId } =
useDialog('DialogContent')
const dialogRef = useRef<HTMLDialogElement | null>(null)
const composedRef = useComposedRefs(ref, dialogRef)
useEffect(() => {
const dialog = dialogRef.current
if (!dialog) return
if (open && !dialog.open) dialog.showModal()
if (!open && dialog.open) {
dialog.close()
triggerRef.current?.focus()
}
}, [open, triggerRef])
return (
<dialog
{...props}
ref={composedRef}
id={contentId}
aria-labelledby={titleId}
aria-describedby={descriptionId}
data-scope="dialog"
data-part="content"
data-state={open ? 'open' : 'closed'}
/* Esc: keep React in charge of the open state. */
onCancel={event => {
event.preventDefault()
setOpen(false)
}}
/* A form with method="dialog", or dialog.close() from elsewhere. */
onClose={() => {
if (open) setOpen(false)
}}
onClick={event => {
onClick?.(event)
if (event.defaultPrevented || event.target !== event.currentTarget) return
/* A click on the dialog element itself outside its box is the backdrop. */
const box = event.currentTarget.getBoundingClientRect()
const inside =
event.clientX >= box.left &&
event.clientX <= box.right &&
event.clientY >= box.top &&
event.clientY <= box.bottom
if (!inside) setOpen(false)
}}
/>
)
}
export function DialogTitle(props: ComponentPropsWithRef<'h2'>) {
const { titleId } = useDialog('DialogTitle')
return <h2 {...props} id={titleId} data-scope="dialog" data-part="title" />
}
export function DialogDescription(props: ComponentPropsWithRef<'p'>) {
const { descriptionId } = useDialog('DialogDescription')
return (
<p {...props} id={descriptionId} data-scope="dialog" data-part="description" />
)
}
export function DialogClose({
asChild,
onClick,
...props
}: ComponentPropsWithRef<'button'> & { asChild?: boolean }) {
const { setOpen } = useDialog('DialogClose')
const Comp = asChild ? Slot : 'button'
return (
<Comp
type={asChild ? undefined : 'button'}
{...props}
data-scope="dialog"
data-part="close"
onClick={(event: MouseEvent<HTMLButtonElement>) => {
onClick?.(event)
if (!event.defaultPrevented) setOpen(false)
}}
/>
)
}
slot.tsxShowHide
import {
Children,
cloneElement,
isValidElement,
type CSSProperties,
type ReactElement,
type ReactNode,
type Ref
} from 'react'
import { composeRefs } from './sui-refs'
type AnyProps = Record<string, unknown>
/**
* Renders its only child with the slot's props merged in, so any component
* can take over the element (`asChild`). Event handlers run child first,
* then the slot's, unless the child's handler called preventDefault.
* Class names join, styles merge, refs compose. No hooks, so it works in
* server components too.
*/
export function Slot({
children,
...slotProps
}: AnyProps & { children?: ReactNode; ref?: Ref<unknown> }) {
const child = Children.only(children)
if (!isValidElement(child)) return null
const element = child as ReactElement<AnyProps>
const childProps = element.props
const merged: AnyProps = { ...slotProps, ...childProps }
for (const key of Object.keys(slotProps)) {
const slotValue = slotProps[key]
const childValue = childProps[key]
if (/^on[A-Z]/.test(key) && typeof slotValue === 'function') {
merged[key] =
typeof childValue === 'function'
? (...args: unknown[]) => {
childValue(...args)
const event = args[0] as { defaultPrevented?: boolean } | undefined
if (!event?.defaultPrevented) slotValue(...args)
}
: slotValue
} else if (key === 'className') {
merged.className = [slotValue, childValue].filter(Boolean).join(' ')
} else if (key === 'style') {
merged.style = {
...(slotValue as CSSProperties),
...(childValue as CSSProperties)
}
}
}
/* Only touch the ref when there is one: a server component rendering
<Slot><Link /></Slot> must not hand a function ref to a client child. */
const slotRef = slotProps.ref as Ref<unknown> | undefined
const childRef = childProps.ref as Ref<unknown> | undefined
if (slotRef && childRef) merged.ref = composeRefs(slotRef, childRef)
else if (slotRef || childRef) merged.ref = slotRef ?? childRef
else delete merged.ref
return cloneElement(element, merged)
}
sui-refs.tsShowHide
import { useCallback, type Ref, type RefCallback } from 'react'
const setRef = <T,>(ref: Ref<T> | undefined, value: T | null) => {
if (typeof ref === 'function') ref(value)
else if (ref) ref.current = value
}
/** One callback ref that feeds several refs. */
export const composeRefs =
<T,>(...refs: (Ref<T> | undefined)[]): RefCallback<T> =>
node => {
for (const ref of refs) setRef(ref, node)
}
/** A user's ref and a component's own ref, as one stable callback ref. */
export const useComposedRefs = <T,>(
outer: Ref<T> | undefined,
inner: Ref<T> | undefined
) => useCallback((node: T | null) => composeRefs(outer, inner)(node), [outer, inner])
sui-use-controllable-state.tsShowHide
import { useCallback, useState } from 'react'
/**
* Controlled when `prop` is defined, uncontrolled otherwise. `onChange`
* fires for every change either way.
*/
export function useControllableState<T>({
prop,
defaultProp,
onChange
}: {
prop: T | undefined
defaultProp: T
onChange?: (value: T) => void
}) {
const [uncontrolled, setUncontrolled] = useState(defaultProp)
const controlled = prop !== undefined
const value = controlled ? prop : uncontrolled
const setValue = useCallback(
(next: T) => {
if (!controlled) setUncontrolled(next)
if (!Object.is(next, value)) onChange?.(next)
},
[controlled, onChange, value]
)
return [value, setValue] as const
}
2. Import from that folder
The files import each other with relative paths, so keep them together, for example in components/ui.
Usage
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogTitle,
DialogTrigger
} from '@syncclouds/ui'<Dialog>
<DialogTrigger>Delete project</DialogTrigger>
<DialogContent>
<DialogTitle>Delete this project?</DialogTitle>
<DialogDescription>You can't undo it.</DialogDescription>
<DialogClose autoFocus>Cancel</DialogClose>
<DialogClose data-tone="danger">Delete</DialogClose>
</DialogContent>
</Dialog>Anatomy
Dialog
├── DialogTrigger
└── DialogContent
├── DialogTitle
├── DialogDescription
└── DialogCloseControlled
Pass open and onOpenChange to open or close it from your own state.
Open: no. Last choice: Nothing yet.
View codeHide code
'use client'
import { useState } from 'react'
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogTitle,
DialogTrigger
} from '@syncclouds/ui'
export function DialogControlled() {
const [open, setOpen] = useState(false)
const [result, setResult] = useState('Nothing yet')
return (
<div className="flex flex-col items-center gap-4">
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger>Publish changes</DialogTrigger>
<DialogContent>
<DialogTitle>Publish to production?</DialogTitle>
<DialogDescription>Visitors will see these changes straight away.</DialogDescription>
<div className="mt-6 flex justify-end gap-3">
<DialogClose onClick={() => setResult('Cancelled')}>Cancel</DialogClose>
<DialogClose data-tone="primary" onClick={() => setResult('Published')}>
Publish
</DialogClose>
</div>
</DialogContent>
</Dialog>
<p className="text-sm">
Open: {open ? 'yes' : 'no'}. Last choice: {result}.
</p>
</div>
)
}Form inside
A form inside the dialog. Save stays open while a field is invalid.
Project name: Checkout
View codeHide code
'use client'
import { useState, type FormEvent } from 'react'
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogTitle,
DialogTrigger
} from '@syncclouds/ui'
export function DialogForm() {
const [open, setOpen] = useState(false)
const [name, setName] = useState('Checkout')
const onSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
setName(String(new FormData(event.currentTarget).get('name')))
setOpen(false)
}
return (
<div className="flex flex-col items-center gap-4">
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger>Rename project</DialogTrigger>
<DialogContent>
<DialogTitle>Rename project</DialogTitle>
<DialogDescription>The new name shows everywhere straight away.</DialogDescription>
<form onSubmit={onSubmit} className="mt-5 flex flex-col gap-2">
<label htmlFor="project-name" className="text-sm font-semibold">
Name
</label>
<input
id="project-name"
name="name"
defaultValue={name}
required
className="h-10 rounded-lg border border-current/40 bg-transparent px-3"
/>
<div className="mt-4 flex justify-end gap-3">
<DialogClose>Cancel</DialogClose>
{/* Submits the form; stays open while a field is invalid. */}
<DialogClose
type="submit"
data-tone="primary"
onClick={event => {
const form = event.currentTarget.form
if (form && !form.reportValidity()) event.preventDefault()
}}
>
Save
</DialogClose>
</div>
</form>
</DialogContent>
</Dialog>
<p className="text-sm">Project name: {name}</p>
</div>
)
}Without the theme
View codeHide code
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogTitle,
DialogTrigger
} from '@syncclouds/ui'
const BUTTON =
'rounded-md px-3 py-1.5 text-sm font-medium focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600'
export function DialogUnstyled() {
return (
<Dialog>
<DialogTrigger className={`${BUTTON} bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900`}>
Show shortcuts
</DialogTrigger>
<DialogContent className="m-auto w-full max-w-sm rounded-xl bg-white p-6 text-zinc-900 shadow-xl backdrop:bg-zinc-950/50 dark:bg-zinc-900 dark:text-zinc-100">
<DialogTitle className="text-lg font-semibold">Keyboard shortcuts</DialogTitle>
<DialogDescription className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
Press these anywhere in the app.
</DialogDescription>
<ul className="mt-4 space-y-2 text-sm">
<li>Ctrl K opens search</li>
<li>G then P goes to projects</li>
</ul>
<DialogClose className={`${BUTTON} mt-6 border border-zinc-300 dark:border-zinc-600`}>
Close
</DialogClose>
</DialogContent>
</Dialog>
)
}Accessibility
Follows the WAI-ARIA Dialog (Modal) pattern.
| Keys | What they do |
|---|---|
| Enter, Space | On the trigger, opens the dialog. |
| Tab, Shift Tab | Moves between the focusable elements inside. The page behind is inert. |
| Escape | Closes the dialog and returns focus to the trigger. |
- Focus moves to the first focusable element, or to one marked autoFocus. For a destructive action, put autoFocus on the safe choice.
- A click on the backdrop closes the dialog.
- Always include DialogTitle and DialogDescription. Hide them visually if the design has no room, but keep them in the markup.
API reference
Every part also accepts the props of the element it renders, including className and ref.
Dialogrenders no element
Holds the open state. Renders no element of its own.
| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | none | Whether the dialog is open, when you control it. |
defaultOpen | boolean | false | Whether it starts open, when the dialog controls itself. |
onOpenChange | (open: boolean) => void | none | Called when it opens or closes, including on Esc and backdrop clicks. |
DialogTriggerrenders <button>
Opens the dialog. Focus returns here when it closes.
| Prop | Type | Default | Description |
|---|---|---|---|
asChild | boolean | false | Use your own button element. |
| Data attribute | Values |
|---|---|
data-state | "open" | "closed" |
DialogContentrenders <dialog>
The native dialog element, opened with showModal(). Named by DialogTitle and described by DialogDescription.
| Data attribute | Values |
|---|---|
data-state | "open" | "closed" |
DialogTitlerenders <h2>
Names the dialog.
DialogDescriptionrenders <p>
Describes the dialog.
DialogCloserenders <button>
Closes the dialog.
| Prop | Type | Default | Description |
|---|---|---|---|
asChild | boolean | false | Use your own button element. |