Dialog

Usage

import { Button } from '@jamie/ui/button'
import {
  Dialog,
  DialogClose,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger
} from '@jamie/ui/dialog'
 
export function DialogDemo() {
  return (
    <Dialog>
      <DialogTrigger render={<Button variant="outline" />}>Share Summary</DialogTrigger>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>Share meeting summary</DialogTitle>
          <DialogDescription>
            This will share the summary of &ldquo;Q3 Revenue Review&rdquo; with all meeting
            participants via email.
          </DialogDescription>
        </DialogHeader>
        <DialogFooter>
          <DialogClose render={<Button variant="outline" />}>Cancel</DialogClose>
          <Button>Share</Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  )
}

With Form

Embed form fields inside a dialog for inline data entry.

'use client'
 
import { Button } from '@jamie/ui/button'
import {
  Dialog,
  DialogClose,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger
} from '@jamie/ui/dialog'
import { Input } from '@jamie/ui/input'
import { Label } from '@jamie/ui/label'
import { useState } from 'react'
 
export function DialogWithFormDemo() {
  const [open, setOpen] = useState(false)
 
  return (
    <Dialog open={open} onOpenChange={setOpen}>
      <DialogTrigger render={<Button variant="outline" />}>Create Agenda</DialogTrigger>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>Create meeting agenda</DialogTitle>
          <DialogDescription>
            Set up the agenda for your upcoming meeting. Participants will be notified.
          </DialogDescription>
        </DialogHeader>
        <div className="grid gap-4 py-4">
          <div className="grid gap-2">
            <Label htmlFor="title">Meeting Title</Label>
            <Input id="title" placeholder="Q4 Budget Planning" />
          </div>
          <div className="grid gap-2">
            <Label htmlFor="topics">Topics</Label>
            <Input id="topics" placeholder="Revenue targets, headcount, vendor costs" />
          </div>
        </div>
        <DialogFooter>
          <DialogClose render={<Button variant="outline" />}>Cancel</DialogClose>
          <Button onClick={() => setOpen(false)}>Create</Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  )
}

Custom Close Button

Replace the default close control with your own buttons in the footer.

import { Button } from '@jamie/ui/button'
import {
  Dialog,
  DialogClose,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger
} from '@jamie/ui/dialog'
 
export function DialogCloseButtonDemo() {
  return (
    <Dialog>
      <DialogTrigger render={<Button variant="outline" />}>Delete Recording</DialogTrigger>
      <DialogContent showCloseButton={false}>
        <DialogHeader>
          <DialogTitle>Delete recording?</DialogTitle>
          <DialogDescription>
            This will permanently remove the recording and its transcript. This action cannot be
            undone.
          </DialogDescription>
        </DialogHeader>
        <DialogFooter>
          <DialogClose render={<Button variant="outline" />}>Keep Recording</DialogClose>
          <Button variant="destructive">Delete</Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  )
}

No Close Button

Use showCloseButton={false} to hide the close button.

import { Button } from '@jamie/ui/button'
import {
  Dialog,
  DialogClose,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger
} from '@jamie/ui/dialog'
 
export function DialogNoCloseButtonDemo() {
  return (
    <Dialog>
      <DialogTrigger render={<Button variant="outline" />}>Export Notes</DialogTrigger>
      <DialogContent showCloseButton={false}>
        <DialogHeader>
          <DialogTitle>Export meeting notes</DialogTitle>
          <DialogDescription>
            Choose a format to export the notes from &ldquo;Weekly Standup&rdquo;.
          </DialogDescription>
        </DialogHeader>
        <DialogFooter>
          <DialogClose render={<Button variant="outline" />}>Cancel</DialogClose>
          <Button>Export as PDF</Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  )
}

Keep actions visible while the content scrolls. Use the showCloseButton prop on DialogFooter.

import { Button } from '@jamie/ui/button'
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger
} from '@jamie/ui/dialog'
 
const actionItems = [
  { title: 'Update Q4 revenue projections', assignee: 'Sarah', due: 'Feb 20' },
  { title: 'Schedule vendor review meeting', assignee: 'Mike', due: 'Feb 22' },
  { title: 'Prepare headcount proposal', assignee: 'Lisa', due: 'Feb 25' },
  { title: 'Review marketing budget allocation', assignee: 'James', due: 'Feb 28' },
  { title: 'Draft customer success playbook', assignee: 'Sarah', due: 'Mar 1' },
  { title: 'Analyze NPS survey by segment', assignee: 'James', due: 'Mar 3' },
  { title: 'Set up retention task force kickoff', assignee: 'Mike', due: 'Mar 5' },
  { title: 'Finalize Q4 content calendar', assignee: 'Lisa', due: 'Mar 7' },
  { title: 'Present product launch marketing plan', assignee: 'James', due: 'Mar 10' },
  { title: 'Coordinate cross-team OKR alignment', assignee: 'Sarah', due: 'Mar 12' }
]
 
export function DialogStickyFooterDemo() {
  return (
    <Dialog>
      <DialogTrigger render={<Button variant="outline" />}>Review Action Items</DialogTrigger>
      <DialogContent className="max-h-[min(80vh,28rem)] flex flex-col">
        <DialogHeader>
          <DialogTitle>Action items</DialogTitle>
          <DialogDescription>
            Review the action items captured during the meeting.
          </DialogDescription>
        </DialogHeader>
        <div className="-mx-4 flex-1 overflow-y-auto px-4">
          <div className="grid gap-3 text-sm">
            {actionItems.map((item) => (
              <div key={item.title} className="rounded-lg border p-3">
                <p className="font-medium">{item.title}</p>
                <p className="text-muted-foreground mt-1">
                  Assigned to {item.assignee} &middot; Due {item.due}
                </p>
              </div>
            ))}
          </div>
        </div>
        <DialogFooter showCloseButton>
          <Button>Send Reminders</Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  )
}

Scrollable Content

Long content can scroll while the header stays in view.

import { Button } from '@jamie/ui/button'
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger
} from '@jamie/ui/dialog'
 
const transcript = [
  {
    name: 'Sarah Chen',
    text: 'Let\u2019s start with the revenue numbers for Q3. Overall, we\u2019re tracking about 12% above our initial projections, which is great news.'
  },
  {
    name: 'Mike Rodriguez',
    text: 'That\u2019s largely driven by the enterprise segment. We closed three major deals that weren\u2019t in the original forecast.'
  },
  {
    name: 'Lisa Park',
    text: 'On the SMB side, we\u2019re seeing higher churn than expected. I think we need to revisit our onboarding process.'
  },
  {
    name: 'Sarah Chen',
    text: 'Agreed. Let\u2019s put together a task force to look at that. Mike, can you lead the effort on improving retention?'
  },
  {
    name: 'Mike Rodriguez',
    text: 'Sure. I\u2019ll set up a kickoff meeting next week and loop in the customer success team. We should also look at the data from our recent NPS survey.'
  },
  {
    name: 'James Wilson',
    text: 'I can pull the NPS data and segment it by account size. That should help us identify where the pain points are.'
  },
  {
    name: 'Lisa Park',
    text: 'One more thing \u2014 the marketing budget for Q4. We need to decide if we\u2019re increasing spend on paid channels or shifting more toward content.'
  },
  {
    name: 'Sarah Chen',
    text: 'Good point. Content has been performing well organically, but paid is what drives the short-term pipeline. We probably need a mix.'
  },
  {
    name: 'Mike Rodriguez',
    text: 'I\u2019d lean toward keeping paid at current levels and investing the incremental budget into content and SEO. The ROI on content compounds over time.'
  },
  {
    name: 'James Wilson',
    text: 'I agree with Mike. Our blog traffic is up 40% quarter-over-quarter, and we\u2019re seeing strong conversion from organic search.'
  },
  {
    name: 'Lisa Park',
    text: 'We should also think about the product launch in November. That\u2019s going to need dedicated marketing spend regardless of the channel mix.'
  },
  {
    name: 'Sarah Chen',
    text: 'Right. Let\u2019s allocate a separate budget for the launch campaign. James, can you put together a proposal by end of next week?'
  },
  {
    name: 'James Wilson',
    text: 'Absolutely. I\u2019ll coordinate with the product team to align messaging and timing.'
  },
  {
    name: 'Sarah Chen',
    text: 'Let\u2019s table that for now and revisit once we have the full Q3 report. I want to make sure we\u2019re making data-driven decisions there.'
  }
]
 
export function DialogScrollableContentDemo() {
  return (
    <Dialog>
      <DialogTrigger render={<Button variant="outline" />}>View Transcript</DialogTrigger>
      <DialogContent className="max-h-[min(80vh,28rem)] flex flex-col">
        <DialogHeader>
          <DialogTitle>Meeting transcript</DialogTitle>
          <DialogDescription>
            Full transcript for &ldquo;Q3 Revenue Review&rdquo;.
          </DialogDescription>
        </DialogHeader>
        <div className="-mx-4 flex-1 overflow-y-auto px-4">
          <div className="grid gap-4 text-sm">
            {transcript.map((entry) => (
              <div key={entry.name}>
                <p className="font-medium">{entry.name}</p>
                <p className="text-muted-foreground">{entry.text}</p>
              </div>
            ))}
          </div>
        </div>
        <DialogFooter showCloseButton />
      </DialogContent>
    </Dialog>
  )
}

Imperative API

Open a dialog from anywhere — no inline trigger, no provider, no hook. Use defineDialog for forms, multi-step flows, or any dialog you want to drive imperatively.

'use client'
 
import { Button } from '@jamie/ui/button'
import {
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  defineDialog
} from '@jamie/ui/dialog'
import { Input } from '@jamie/ui/input'
import { Label } from '@jamie/ui/label'
import { toast } from '@jamie/ui/sonner'
import { useState } from 'react'
 
interface AddTagPayload {
  onConfirm: (tag: { name: string; description: string }) => void
}
 
const addTagDialog = defineDialog<AddTagPayload>(({ onConfirm }) => (
  <AddTagDialogContent onConfirm={onConfirm} />
))
 
function AddTagDialogContent({ onConfirm }: AddTagPayload) {
  // Internal state — fresh on every open thanks to lazy mounting.
  const [name, setName] = useState('')
  const [description, setDescription] = useState('')
 
  const handleSave = () => {
    const trimmedName = name.trim()
    if (!trimmedName) return
    onConfirm({ name: trimmedName, description: description.trim() })
    addTagDialog.close()
  }
 
  return (
    <DialogContent>
      <DialogHeader>
        <DialogTitle>Add tag</DialogTitle>
        <DialogDescription>
          Tags help you organize meetings. Pick a short, memorable name.
        </DialogDescription>
      </DialogHeader>
      <div className="grid gap-4 py-2">
        <div className="grid gap-2">
          <Label htmlFor="tag-name">Name</Label>
          <Input
            id="tag-name"
            value={name}
            onChange={(e) => setName(e.target.value)}
            placeholder="e.g. interviews"
            onKeyDown={(e) => {
              if (e.key === 'Enter' && name.trim()) handleSave()
            }}
          />
        </div>
        <div className="grid gap-2">
          <Label htmlFor="tag-description">Description (optional)</Label>
          <Input
            id="tag-description"
            value={description}
            onChange={(e) => setDescription(e.target.value)}
            placeholder="When and why to use this tag"
          />
        </div>
      </div>
      <DialogFooter>
        <Button variant="outline" onClick={() => addTagDialog.close()}>
          Cancel
        </Button>
        <Button onClick={handleSave} disabled={!name.trim()}>
          Save
        </Button>
      </DialogFooter>
    </DialogContent>
  )
}
 
export function DialogImperativeDemo() {
  return (
    <>
      <Button
        variant="outline"
        onClick={() =>
          addTagDialog.open({
            onConfirm: ({ name }) => toast.success(`Tag "${name}" created`)
          })
        }
      >
        Add Tag
      </Button>
      <addTagDialog.Root />
    </>
  )
}

Define the dialog

defineDialog<Payload>(render) returns a { open, close, Root } object.


Keep it module-scope so any caller can import it. The render function receives the typed payload from .open(payload).

import { Button } from '@jamie/ui/button'
import {
  defineDialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle
} from '@jamie/ui/dialog'
 
export const addTagDialog = defineDialog<{
  onConfirm: (tag: { name: string; description: string }) => void
}>((payload) => <AddTagDialogContent {...payload} />)

Internal state

Pull the body into a sub-component so state lives inside it

import { Input } from '@jamie/ui/input'
import { Label } from '@jamie/ui/label'
import { useState } from 'react'
 
function AddTagDialogContent({
  onConfirm
}: {
  onConfirm: (tag: { name: string; description: string }) => void
}) {
  // Fresh on every open thanks to lazy mounting.
  const [name, setName] = useState('')
  const [description, setDescription] = useState('')
 
  const handleSave = () => {
    const trimmed = name.trim()
    if (!trimmed) return
    onConfirm({ name: trimmed, description: description.trim() })
    addTagDialog.close()
  }
 
  return (
    <DialogContent>
      <DialogHeader>
        <DialogTitle>Add tag</DialogTitle>
        <DialogDescription>
          Tags help you organize meetings. Pick a short, memorable name.
        </DialogDescription>
      </DialogHeader>
      <div className="grid gap-4 py-2">
        <div className="grid gap-2">
          <Label htmlFor="tag-name">Name</Label>
          <Input id="tag-name" value={name} onChange={(e) => setName(e.target.value)} />
        </div>
        <div className="grid gap-2">
          <Label htmlFor="tag-description">Description (optional)</Label>
          <Input
            id="tag-description"
            value={description}
            onChange={(e) => setDescription(e.target.value)}
          />
        </div>
      </div>
      <DialogFooter>
        <Button variant="outline" onClick={() => addTagDialog.close()}>
          Cancel
        </Button>
        <Button onClick={handleSave} disabled={!name.trim()}>
          Save
        </Button>
      </DialogFooter>
    </DialogContent>
  )
}

Mount the Root once

Render <addTagDialog.Root /> once, somewhere persistent — typically a top-level AppDialogs component.

export function AppDialogs() {
  return <addTagDialog.Root />
}

The Root portals to <body> and lazy-mounts its content only when the dialog opens.

Open from anywhere

Import the dialog and call .open(payload). Payload is typed.

import { toast } from '@jamie/ui/sonner'
 
addTagDialog.open({
  onConfirm: ({ name }) => {
    createTag({ name })
    toast.success(`Tag "${name}" created`)
  }
})