Dialog
A modal window for forms and confirmations — a bottom sheet on phones, plus an imperative confirm().
'use client';
import { Button, Dialog, Field, Input } from '@akhatmirzo/ui';
import { useState } from 'react';
export default function DialogBasic() {
const [open, setOpen] = useState(false);
const [name, setName] = useState('Sarvar');
const [phone, setPhone] = useState('+998 90 123 45 67');
return (
<Dialog open={open} onOpenChange={setOpen}>
<Dialog.Trigger asChild>
<Button variant="primary">Profilni tahrirlash</Button>
</Dialog.Trigger>
<Dialog.Content title="Profilni tahrirlash" description="O'zgarishlar darhol saqlanadi.">
<div className="flex flex-col gap-4">
<Field label="Ism">
<Input value={name} onChange={(e) => setName(e.target.value)} />
</Field>
<Field label="Telefon" description="Tasdiqlash uchun SMS kod yuboriladi">
<Input value={phone} onChange={(e) => setPhone(e.target.value)} />
</Field>
</div>
<Dialog.Footer>
<Dialog.Close asChild>
<Button>Bekor qilish</Button>
</Dialog.Close>
<Button variant="primary" onClick={() => setOpen(false)}>
Saqlash
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog>
);
}
Import
import { Dialog, ConfirmDialog, ConfirmProvider, confirm } from '@akhatmirzo/ui';When to use
- To focus the user on one task — a short form, editing details, an important warning. The rest of the page is blocked until the dialog closes.
- To confirm a destructive action —
confirm()orConfirmDialog. One line of code gives you atrue/falseanswer. - Long forms or filters fit better in a side panel — a
Drawerkeeps the page in view. - Small, non-blocking content (a filter, a color picker) doesn't need a modal — use a
Popover. - Result messages ("Saved") don't need a window at all —
toast()is enough.
Anatomy
Dialog owns the state (open, defaultOpen, onOpenChange). The other parts:
| Part | What it does |
|---|---|
Dialog.Trigger | Opens the dialog. Pass your own Button with asChild |
Dialog.Content | The window: backdrop, title, description, ✕ button and focus trap |
Dialog.Footer | The button row — right-aligned with an 8px gap |
Dialog.Close | Closes the dialog when clicked (use with asChild) |
<Dialog open={open} onOpenChange={setOpen}>
<Dialog.Trigger asChild>
<Button>Open</Button>
</Dialog.Trigger>
<Dialog.Content title="Edit profile" description="Changes are saved right away.">
{/* form */}
<Dialog.Footer>
<Dialog.Close asChild>
<Button>Cancel</Button>
</Dialog.Close>
<Button variant="primary">Save</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog>title is required: screen readers announce the dialog by it. If it shouldn't be visible, add
hideTitle — don't remove it.
Sizes
size sets the width: sm — 400px, md — 520px (default), lg — 640px, xl — 800px, full
— the whole screen. The dialog is never taller than the viewport minus 32px; long content
scrolls inside it.
'use client';
import { Button, Dialog, type DialogSize } from '@akhatmirzo/ui';
const sizes: { size: DialogSize; width: string }[] = [
{ size: 'sm', width: '400px' },
{ size: 'md', width: '520px' },
{ size: 'lg', width: '640px' },
{ size: 'xl', width: '800px' },
{ size: 'full', width: 'butun ekran' },
];
export default function DialogSizes() {
return (
<div className="flex flex-wrap items-center gap-3">
{sizes.map(({ size, width }) => (
<Dialog key={size}>
<Dialog.Trigger asChild>
<Button variant="outline">{size}</Button>
</Dialog.Trigger>
<Dialog.Content
title={`O'lcham: ${size}`}
description={`Kenglik — ${width}.`}
size={size}
>
<p className="m-0 text-sm text-text-muted">
Ekran tor bo'lsa, chetlarda 16px joy qoladi. Telefonda oyna pastdan chiqadi.
</p>
<Dialog.Footer>
<Dialog.Close asChild>
<Button variant="primary">Tushunarli</Button>
</Dialog.Close>
</Dialog.Footer>
</Dialog.Content>
</Dialog>
))}
</div>
);
}
Options
dismissible={false}— clicking outside doesn't close the dialog (Escstill does).closeButton={false}— removes the ✕ button in the top-right corner.hideTitle— hides the title visually; screen readers still read it.mobileSheet={false}— keeps the dialog centered on phones too. By default, screens narrower than 640px get a bottom sheet instead.
'use client';
import { Button, Dialog } from '@akhatmirzo/ui';
export default function DialogOptions() {
return (
<div className="flex flex-wrap items-center gap-3">
<Dialog>
<Dialog.Trigger asChild>
<Button variant="outline">dismissible=false</Button>
</Dialog.Trigger>
<Dialog.Content
title="Shartlarni o'qing"
description="Tashqariga bosish yopmaydi — faqat tugma yoki Esc."
dismissible={false}
>
<Dialog.Footer>
<Dialog.Close asChild>
<Button variant="primary">Roziman</Button>
</Dialog.Close>
</Dialog.Footer>
</Dialog.Content>
</Dialog>
<Dialog>
<Dialog.Trigger asChild>
<Button variant="outline">closeButton=false</Button>
</Dialog.Trigger>
<Dialog.Content title="✕ tugmasisiz" closeButton={false} size="sm">
<p className="m-0 text-sm text-text-muted">Oyna faqat pastdagi tugma bilan yopiladi.</p>
<Dialog.Footer>
<Dialog.Close asChild>
<Button variant="primary">Yopish</Button>
</Dialog.Close>
</Dialog.Footer>
</Dialog.Content>
</Dialog>
<Dialog>
<Dialog.Trigger asChild>
<Button variant="outline">hideTitle</Button>
</Dialog.Trigger>
<Dialog.Content title="Mahsulot rasmi" hideTitle size="sm">
<div className="grid h-40 place-items-center rounded-xl bg-surface-muted text-sm text-text-muted">
Sarlavha faqat ekran o'quvchi uchun
</div>
</Dialog.Content>
</Dialog>
<Dialog>
<Dialog.Trigger asChild>
<Button variant="outline">mobileSheet=false</Button>
</Dialog.Trigger>
<Dialog.Content
title="Doim markazda"
description="Telefonda ham pastdan chiqmaydi."
mobileSheet={false}
size="sm"
>
<Dialog.Footer>
<Dialog.Close asChild>
<Button variant="primary">Yaxshi</Button>
</Dialog.Close>
</Dialog.Footer>
</Dialog.Content>
</Dialog>
</div>
);
}
While submitting
To keep a submitting form from being closed by accident, turn off dismissible and ignore close
requests in onOpenChange — then neither an outside click nor Esc closes it.
'use client';
import { Button, Dialog, Field, Input, Textarea, toast } from '@akhatmirzo/ui';
import { useState } from 'react';
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
export default function DialogAsyncSubmit() {
const [open, setOpen] = useState(false);
const [sending, setSending] = useState(false);
const send = async () => {
setSending(true);
await wait(1500);
setSending(false);
setOpen(false);
toast.success('Fikringiz yuborildi');
};
return (
<Dialog open={open} onOpenChange={(next) => !sending && setOpen(next)}>
<Dialog.Trigger asChild>
<Button>Fikr bildirish</Button>
</Dialog.Trigger>
<Dialog.Content title="Fikr bildirish" size="lg" dismissible={!sending}>
<div className="flex flex-col gap-4">
<Field label="Mavzu" required>
<Input placeholder="Masalan: yetkazib berish" />
</Field>
<Field label="Xabar">
<Textarea minRows={4} placeholder="Fikringizni yozing..." />
</Field>
</div>
<Dialog.Footer>
<Dialog.Close asChild>
<Button disabled={sending}>Bekor qilish</Button>
</Dialog.Close>
<Button variant="primary" loading={sending} onClick={send}>
Yuborish
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog>
);
}
Confirmation: confirm()
confirm() is an imperative API: it opens a dialog and returns a Promise<boolean>. "Confirm"
resolves to true; "Cancel", Esc or an outside click resolve to false.
'use client';
import { Button, confirm, toast } from '@akhatmirzo/ui';
import { Trash2 } from 'lucide-react';
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
export default function DialogConfirm() {
const removeOrder = async () => {
const ok = await confirm({
title: "Buyurtma o'chirilsinmi?",
description: "Bu amalni qaytarib bo'lmaydi.",
confirmText: "O'chirish",
variant: 'danger',
});
if (ok) toast.success("Buyurtma #1042 o'chirildi");
};
const publish = async () => {
const ok = await confirm({
title: 'Mahsulot nashr qilinsinmi?',
description: "Mahsulot do'konda hammaga ko'rinadi.",
confirmText: 'Nashr qilish',
// Promise tugaguncha tugma loading, oyna ochiq turadi
onConfirm: () => wait(1500),
});
if (ok) toast.success('Mahsulot nashr qilindi');
};
return (
<div className="flex flex-wrap items-center gap-3">
<Button variant="danger" leftIcon={<Trash2 />} onClick={removeOrder}>
O'chirish
</Button>
<Button variant="primary" onClick={publish}>
Nashr qilish
</Button>
</div>
);
}
const ok = await confirm({
title: 'Delete this order?',
description: "This can't be undone.",
confirmText: 'Delete',
variant: 'danger',
});
if (ok) await deleteOrder(id);variant: 'danger'— the confirm button turns red and initial focus goes to "Cancel", so an accidentalEnterdoesn't delete anything.- If
onConfirmreturns a Promise, the button switches toloadingand the dialog stays open until it settles (Escand outside clicks are blocked too). If the Promise rejects, the dialog stays open so the user can retry or cancel.
ConfirmProvider is required
confirm() needs a single <ConfirmProvider /> somewhere in your app. Without it, confirm()
rejects with an error saying the provider wasn't found.
'use client';
import { ConfirmProvider } from '@akhatmirzo/ui';
import type { ReactNode } from 'react';
export function Providers({ children }: { children: ReactNode }) {
return <ConfirmProvider>{children}</ConfirmProvider>;
}ConfirmProvider can wrap your app or sit next to it on its own. Its labels prop sets the
default button texts for every confirm() call: labels={{ confirm: 'Confirm', cancel: 'Cancel' }}
— handy for an English interface, since the built-in texts are Uzbek.
ConfirmDialog — the declarative version
When the confirmation should live in JSX (for example, with a specific trigger), use
ConfirmDialog. It takes the same options as confirm(), plus trigger and onCancel.
'use client';
import { Button, ConfirmDialog, toast } from '@akhatmirzo/ui';
export default function DialogConfirmDeclarative() {
return (
<ConfirmDialog
trigger={<Button variant="outline">Hisobdan chiqish</Button>}
title="Hisobdan chiqilsinmi?"
description="Qayta kirish uchun SMS kod kerak bo'ladi."
confirmText="Chiqish"
onConfirm={() => toast('Hisobdan chiqdingiz')}
onCancel={() => toast.info('Hisobda qoldingiz')}
/>
);
}
Select and Combobox inside a dialog
Select, Combobox, DropdownMenu
and Popover inside Dialog.Content detect the modal layer on their own: their
panels open above the dialog and the focus trap doesn't get in the way. No z-index or
container tweaks needed. The same goes for a Drawer.
'use client';
import { Button, Combobox, Dialog, Field, Select } from '@akhatmirzo/ui';
import { useState } from 'react';
const cities = ['Toshkent', 'Samarqand', 'Buxoro', 'Andijon', "Farg'ona", 'Namangan'].map(
(label) => ({ value: label.toLowerCase().replace(/'/g, ''), label }),
);
export default function DialogWithSelect() {
const [city, setCity] = useState<string | null>('toshkent');
return (
<Dialog>
<Dialog.Trigger asChild>
<Button variant="outline">Yetkazib berish manzili</Button>
</Dialog.Trigger>
<Dialog.Content
title="Yetkazib berish manzili"
description="Ro'yxatlar oyna ustida ochiladi."
>
<div className="flex flex-col gap-4">
<Field label="Shahar">
<Combobox options={cities} value={city} onValueChange={setCity} clearable />
</Field>
<Field label="Yetkazish vaqti">
<Select defaultValue="morning">
<Select.Item value="morning">Ertalab (9:00–12:00)</Select.Item>
<Select.Item value="day">Kunduzi (12:00–17:00)</Select.Item>
<Select.Item value="evening">Kechqurun (17:00–21:00)</Select.Item>
</Select>
</Field>
</div>
<Dialog.Footer>
<Dialog.Close asChild>
<Button>Bekor qilish</Button>
</Dialog.Close>
<Dialog.Close asChild>
<Button variant="primary">Saqlash</Button>
</Dialog.Close>
</Dialog.Footer>
</Dialog.Content>
</Dialog>
);
}
Props
Dialog
Accepts the props of the Radix Dialog Root.
| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | — | Whether it is open (controlled) |
defaultOpen | boolean | — | Open on first render |
onOpenChange | (open: boolean) => void | — | Called when it opens or closes |
modal | boolean | true | Blocks the rest of the page |
Dialog.Content
Other <div> attributes, ref and Radix events (onEscapeKeyDown, onOpenAutoFocus,
onInteractOutside ...) are passed to the panel.
| Prop | Type | Default | Description |
|---|---|---|---|
titlerequired | ReactNode | — | The title — also used by screen readers |
description | ReactNode | — | Text under the title |
size | 'sm' | 'md' | 'lg' | 'xl' | 'full' | 'md' | 400 / 520 / 640 / 800px / full screen |
hideTitle | boolean | false | Hides the title visually (still read aloud) |
closeButton | boolean | true | ✕ button in the top-right corner |
dismissible | boolean | true | false — outside clicks don't close it (Esc still does) |
mobileSheet | boolean | true | Bottom sheet on screens narrower than 640px |
overlayClassName | string | — | Class for the backdrop |
container | HTMLElement | null | — | Portal container (defaults to document.body) |
labels | { close?: string } | { close: 'Yopish' } | Accessible name of the ✕ button |
Dialog.Trigger, Dialog.Close
Both accept asChild — pass your own element, usually a Button. Dialog.Footer is a plain
<div> and takes any div attributes.
confirm() and ConfirmDialog
confirm(options): Promise<boolean>. ConfirmDialog takes the same options as props.
| Prop | Type | Default | Description |
|---|---|---|---|
titlerequired | ReactNode | — | The question |
description | ReactNode | — | A short explanation |
confirmText | ReactNode | 'Tasdiqlash' | Confirm button label |
cancelText | ReactNode | 'Bekor qilish' | Cancel button label |
variant | 'default' | 'danger' | 'default' | danger — red button, focus starts on Cancel |
onConfirm | () => unknown | — | If it returns a Promise — loading until it settles; stays open on error |
ConfirmDialog only:
| Prop | Type | Default | Description |
|---|---|---|---|
trigger | ReactNode | — | The element that opens it |
open | boolean | — | Controlled mode |
defaultOpen | boolean | false | Open on first render |
onOpenChange | (open: boolean) => void | — | Called when it opens or closes |
onCancel | () => void | — | Called on cancel (button, Esc or outside click) |
labels | { confirm?: string; cancel?: string } | — | Default button labels |
ConfirmProvider
| Prop | Type | Default | Description |
|---|---|---|---|
labels | { confirm?: string; cancel?: string } | — | Default labels for every confirm() dialog |
children | ReactNode | — | Optional: it can wrap your app |
Data attributes
| Attribute | On | Value |
|---|---|---|
data-state | panel | open · closed |
data-size | panel | sm · md · lg · xl · full |
data-sheet | panel | present when shown as a bottom sheet |
To choose the initial focus yourself, put a data-autofocus attribute on the element.
Keyboard
| Key | Action |
|---|---|
Tab | Next element inside the dialog (focus never leaves it) |
Shift + Tab | Previous element |
Esc | Closes the dialog; focus returns to where it came from |
Accessibility
- Role and name. The panel is
role="dialog"(role="alertdialog"forconfirm()andConfirmDialog), witharia-labelledbypointing to the title andaria-describedbyto the description. Without a description,aria-describedbyis left out entirely. - Focus trap. While open, focus cycles inside the dialog; the rest of the page is hidden from screen readers and can't scroll.
- Initial focus:
[data-autofocus]→ the first field (input,textarea,select) → the first button (other than ✕) → the panel itself. - Returning focus. On close, focus goes back to
Dialog.Trigger. Without a trigger (controlled mode orconfirm()), it returns to whatever was focused before the dialog opened. - The ✕ button is named "Yopish" by default — change it with
labels.close. - Reduced motion. With
prefers-reduced-motion, the slide animation becomes a plain fade.