react-confirm-alert

Custom UI

Put your own form, list or warning in a dialog. Plain HTML already looks right.

Write the content as plain HTML. Inside a dialog it gets the default look with no class names.

The playground has each example written four ways: openDialog, <Dialog>, confirm with content, and 3.0 customUI.

A form with validation

openDialog<R>() resolves what your content passes to close(). DialogLayout lays out the title, your fields and the buttons.

Q3 report.pdf
rename-open-dialog.tsx
'use client'
import { useState } from 'react'
import { DialogLayout, openDialog } from 'react-confirm-alert'

function RenameForm({
  name,
  close,
}: {
  name: string
  close: (name: string | null) => void
}) {
  const [value, setValue] = useState(name)
  const [error, setError] = useState('')
  return (
    <form
      onSubmit={(e) => {
        e.preventDefault()
        if (value.trim()) close(value.trim())
        else setError('Give it a name')
      }}
    >
      <DialogLayout
        title="Rename file"
        actions={[
          { label: 'Cancel', onClick: () => close(null) },
          { label: 'Rename', variant: 'primary', type: 'submit' },
        ]}
      >
        <label htmlFor="rename-name">Name</label>
        <input
          id="rename-name"
          data-autofocus
          value={value}
          aria-invalid={!!error}
          onChange={(e) => {
            setValue(e.target.value)
            setError('')
          }}
        />
        {error && <p data-slot="error">{error}</p>}
      </DialogLayout>
    </form>
  )
}

export function RenameOpenDialog() {
  const [name, setName] = useState('Q3 report.pdf')
  async function rename() {
    // close() value, or undefined on Escape and outside clicks.
    const next = await openDialog<string | null>({
      render: ({ close }) => <RenameForm name={name} close={close} />,
    }).result
    if (next) setName(next)
  }
  return (
    <div className="row">
      <span>{name}</span>
      <button onClick={rename}>Rename</button>
    </div>
  )
}

Pick from a list

Plain radios take the accent colour of the main button.

Nobody yet
assign-open-dialog.tsx
'use client'
import { useState } from 'react'
import { DialogLayout, openDialog } from 'react-confirm-alert'

const members = ['Ana Silva', 'Ben Ito', 'Chai Wong']

function Members({
  value,
  onChange,
}: {
  value: string
  onChange: (name: string) => void
}) {
  return members.map((m) => (
    <label key={m}>
      <input
        type="radio"
        name="member"
        value={m}
        checked={m === value}
        onChange={() => onChange(m)}
      />{' '}
      {m}
    </label>
  ))
}

function Picker({
  close,
  dismiss,
}: {
  close: (name: string) => void
  dismiss: () => void
}) {
  const [picked, setPicked] = useState(members[0]!)
  return (
    <DialogLayout
      title="Assign to"
      message="They get an email with a link to the task."
      actions={[
        { label: 'Cancel', onClick: dismiss },
        {
          label: 'Assign',
          variant: 'primary',
          onClick: () => close(picked),
        },
      ]}
    >
      <Members value={picked} onChange={setPicked} />
    </DialogLayout>
  )
}

export function AssignOpenDialog() {
  const [owner, setOwner] = useState('Nobody yet')
  async function assign() {
    const picked = await openDialog<string>({
      render: ({ close, dismiss }) => (
        <Picker close={close} dismiss={dismiss} />
      ),
    }).result
    if (picked) setOwner(picked)
  }
  return (
    <div className="row">
      <span>{owner}</span>
      <button onClick={assign}>Assign</button>
    </div>
  )
}

Type the name to delete

An action whose onClick returns a Promise shows a spinner. The dialog stays open until it settles, and Escape waits too.

acme-web: Active
delete-open-dialog.tsx
'use client'
import { useState } from 'react'
import { DialogLayout, openDialog } from 'react-confirm-alert'

const project = 'acme-web'
const deleteProject = () => new Promise((r) => setTimeout(r, 1200))

function DeleteForm({
  close,
  dismiss,
}: {
  close: (deleted: boolean) => void
  dismiss: () => void
}) {
  const [typed, setTyped] = useState('')
  return (
    <DialogLayout
      title={`Delete ${project}?`}
      message="This deletes the project and its deploys."
      actions={[
        { label: 'Cancel', onClick: dismiss },
        // A Promise keeps the button busy and the dialog open.
        {
          label: 'Delete',
          variant: 'danger',
          disabled: typed !== project,
          onClick: () => deleteProject().then(() => close(true)),
        },
      ]}
    >
      <label htmlFor="delete-name">Type {project} to confirm</label>
      <input
        id="delete-name"
        data-autofocus
        autoComplete="off"
        value={typed}
        onChange={(e) => setTyped(e.target.value)}
      />
    </DialogLayout>
  )
}

export function DeleteOpenDialog() {
  const [status, setStatus] = useState('Active')
  async function remove() {
    const deleted = await openDialog<boolean>({
      render: ({ close, dismiss }) => (
        <DeleteForm close={close} dismiss={dismiss} />
      ),
    }).result
    if (deleted) setStatus('Deleted')
  }
  return (
    <div className="row">
      <span>
        {project}: {status}
      </span>
      <button onClick={remove}>Delete</button>
    </div>
  )
}

customUI from 3.0

customUI keeps its 3.0 look. Add skin: true and your content gets the new look. configureDialogs({ confirmAlert: { skin: true } }) turns it on for every call.

No invites yet
invite-custom-ui.tsx
'use client'
import { useState } from 'react'
import { confirmAlert } from 'react-confirm-alert'

type Invite = { email: string; role: string }

function InviteForm({ done }: { done: (invite?: Invite) => void }) {
  const [invite, set] = useState<Invite>({
    email: '',
    role: 'Viewer',
  })
  return (
    <form
      onSubmit={(e) => {
        e.preventDefault()
        done(invite)
      }}
    >
      <h1>Invite a teammate</h1>
      <p>They can open every board in this workspace.</p>
      <label htmlFor="invite-email">Email</label>
      <input
        id="invite-email"
        type="email"
        required
        data-autofocus
        value={invite.email}
        onChange={(e) => set({ ...invite, email: e.target.value })}
      />
      <label htmlFor="invite-role">Role</label>
      <select
        id="invite-role"
        value={invite.role}
        onChange={(e) => set({ ...invite, role: e.target.value })}
      >
        <option>Viewer</option>
        <option>Editor</option>
        <option>Admin</option>
      </select>
      <div className="react-confirm-alert-button-group">
        <button type="button" onClick={() => done()}>
          Cancel
        </button>
        <button data-variant="primary">Send invite</button>
      </div>
    </form>
  )
}

export function InviteCustomUI() {
  const [sent, setSent] = useState('No invites yet')
  const invite = () =>
    confirmAlert({
      skin: true,
      customUI: ({ onClose }) => (
        <InviteForm
          done={(i) => {
            if (i) setSent(`${i.email} as ${i.role}`)
            onClose()
          }}
        />
      ),
    })
  return (
    <div className="row">
      <span>{sent}</span>
      <button onClick={invite}>Invite</button>
    </div>
  )
}

Reference

Which API to use

UseWhen you want
openDialog<R>({ render })A value back from your content.
<Dialog open onOpenChange>Content that reads your component's state.
confirm({ content })A confirm with something extra under the message. alert and prompt take content too.
confirmAlert({ skin, customUI })The 3.0 API with the new look.

Elements with the default look

h1 to h3, p, label, input, textarea, select and button. The rules sit under :where(), so any class you add wins. Tailwind v4 preflight does not remove them.

To style the content yourself, add classes, mark it up with data-slot, or drop the default look with unstyled: true.

On this page