Dialog
Okno nałożone na stronę, które skupia uwagę na jednym zadaniu i blokuje wszystko inne, dopóki zadanie nie zostanie zakończone albo okno zamknięte.
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
export default function DialogDemo() {
return (
<Dialog>
<DialogTrigger render={<Button variant="outline" />}>
Edytuj profil
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Edytuj profil</DialogTitle>
<DialogDescription>
Zmiany zobaczą wszyscy w twojej przestrzeni roboczej.
</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field>
<FieldLabel htmlFor="profile-name">Imię i nazwisko</FieldLabel>
<Input id="profile-name" defaultValue="Ada Lovelace" />
</Field>
<Field>
<FieldLabel htmlFor="profile-username">Nazwa użytkownika</FieldLabel>
<Input id="profile-username" defaultValue="ada" />
</Field>
</FieldGroup>
<DialogFooter>
<DialogClose render={<Button variant="outline" />}>
Anuluj
</DialogClose>
<Button>Zapisz zmiany</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}Instalacja
$ pnpm dlx shadcn@latest add https://www.prfct.dev/r/dialog.jsonUżycie
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"<Dialog>
<DialogTrigger render={<Button variant="outline" />}>Edit profile</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit profile</DialogTitle>
<DialogDescription>Changes are visible to everyone in your workspace.</DialogDescription>
</DialogHeader>
{/* … */}
<DialogFooter>
<DialogClose render={<Button variant="outline" />}>Cancel</DialogClose>
<Button>Save changes</Button>
</DialogFooter>
</DialogContent>
</Dialog>DialogContent sam renderuje portal, przyciemnione tło i okno. Wyzwalacze i przyciski zamykania przyjmują prop render, więc element, który otwiera okno, jest prawdziwym Button z prfct, a nie opakowaniem wokół niego.
Przykłady
Formularz
Najczęstsze okno dialogowe: krótki formularz, który edytuje jedną rzecz. Po otwarciu okna fokus trafia do pierwszego pola, a po zamknięciu wraca do wyzwalacza. Ogranicz się do kilku pól — wszystko dłuższe zasługuje na osobną stronę albo Sheet. Umieść pola i stopkę w jednym elemencie <form>, żeby Enter wysyłał formularz.
"use client"
import * as React from "react"
import { PlusIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
InputGroupText,
} from "@/components/ui/input-group"
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"
// Accents become plain letters (é → e, ł → l) instead of disappearing.
const slugify = (value: string) =>
value
.normalize("NFD")
.replace(/\p{Diacritic}/gu, "")
.replace(/ł/gi, "l")
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")
export default function DialogForm() {
const [open, setOpen] = React.useState(false)
const [name, setName] = React.useState("")
return (
<Dialog
open={open}
onOpenChange={(next) => {
setOpen(next)
if (!next) setName("")
}}
>
<DialogTrigger render={<Button />}>
<PlusIcon data-icon="inline-start" />
Nowy projekt
</DialogTrigger>
<DialogContent>
<form
className="flex flex-col gap-5"
onSubmit={(event) => {
event.preventDefault()
setOpen(false)
}}
>
<DialogHeader>
<DialogTitle>Utwórz projekt</DialogTitle>
<DialogDescription>
Projekt grupuje zgłoszenia, wdrożenia i dokumentację jednego produktu.
</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field>
<FieldLabel htmlFor="project-name">Nazwa</FieldLabel>
<Input
id="project-name"
placeholder="Atlas"
required
value={name}
onChange={(event) => setName(event.target.value)}
/>
</Field>
<Field>
<FieldLabel htmlFor="project-url">Adres URL</FieldLabel>
<InputGroup>
<InputGroupAddon>
<InputGroupText>prfct.dev/</InputGroupText>
</InputGroupAddon>
<InputGroupInput
id="project-url"
readOnly
value={slugify(name) || "atlas"}
className="text-muted-foreground"
/>
</InputGroup>
<FieldDescription>
Tworzony na podstawie nazwy. Możesz go później zmienić.
</FieldDescription>
</Field>
<Field>
<FieldLabel htmlFor="project-template">Szablon</FieldLabel>
<NativeSelect
id="project-template"
defaultValue="web"
className="w-full"
>
<NativeSelectOption value="blank">Pusty</NativeSelectOption>
<NativeSelectOption value="web">
Aplikacja internetowa
</NativeSelectOption>
<NativeSelectOption value="docs">
Strona z dokumentacją
</NativeSelectOption>
<NativeSelectOption value="api">Usługa API</NativeSelectOption>
</NativeSelect>
</Field>
</FieldGroup>
<DialogFooter>
<DialogClose render={<Button variant="outline" />}>
Anuluj
</DialogClose>
<Button type="submit">Utwórz projekt</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}Przewijana treść
Gdy treść jest wyższa niż obszar widoku, przewijaj tylko środkową część okna, żeby tytuł i akcje pozostały widoczne. Samo okno nigdy nie jest większe niż obszar widoku pomniejszony o margines 1 rem z każdej strony.
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
const sections = [
{
title: "1. Twoje konto",
body: "Odpowiadasz za aktywność na swoim koncie i za bezpieczeństwo danych logowania. Jeśli zauważysz nieuprawniony dostęp, natychmiast nas o tym poinformuj.",
},
{
title: "2. Dozwolone korzystanie",
body: "Nie używaj usługi do łamania prawa, naruszania praw innych osób, rozpowszechniania złośliwego oprogramowania ani zakłócania jej prawidłowego działania.",
},
{
title: "3. Twoje treści",
body: "To, co tworzysz, należy do ciebie. Udzielasz nam ograniczonej licencji na przechowywanie i wyświetlanie tych treści wyłącznie w zakresie niezbędnym do świadczenia ci usługi.",
},
{
title: "4. Płatności",
body: "Płatne plany odnawiają się automatycznie z końcem każdego okresu rozliczeniowego. Możesz zrezygnować w dowolnym momencie — rezygnacja zacznie obowiązywać z końcem bieżącego okresu.",
},
{
title: "5. Dostępność",
body: "Dążymy do miesięcznej dostępności na poziomie 99,95% i publikujemy incydenty na naszej stronie statusu. Planowane prace serwisowe ogłaszamy z co najmniej 48-godzinnym wyprzedzeniem.",
},
{
title: "6. Rozwiązanie umowy",
body: "Możesz zamknąć konto w dowolnym momencie. Możemy zawiesić konta naruszające niniejsze warunki i podamy ci powód, chyba że zabrania nam tego prawo.",
},
{
title: "7. Zmiany",
body: "O istotnych zmianach powiadomimy cię co najmniej 30 dni przed ich wejściem w życie. Dalsze korzystanie z usługi po tym terminie oznacza akceptację nowych warunków.",
},
]
export default function DialogScrollable() {
return (
<Dialog>
<DialogTrigger render={<Button variant="outline" />}>
Przejrzyj warunki
</DialogTrigger>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle>Warunki korzystania z usługi</DialogTitle>
<DialogDescription>Ostatnia aktualizacja: 1 września 2026 r.</DialogDescription>
</DialogHeader>
{/* Only the body scrolls; the title and actions stay in view. */}
<div className="-mx-6 max-h-[min(22rem,50dvh)] scrollbar-thin overflow-y-auto border-y px-6 py-5">
<div className="flex flex-col gap-5">
{sections.map((section) => (
<section key={section.title} className="flex flex-col gap-1.5">
<h3 className="text-sm font-semibold text-foreground">
{section.title}
</h3>
<p className="text-sm leading-[1.375rem] text-muted-foreground">
{section.body}
</p>
</section>
))}
</div>
</div>
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<DialogClose render={<Button variant="outline" />}>
Odrzuć
</DialogClose>
<DialogClose render={<Button />}>Zaakceptuj</DialogClose>
</div>
</DialogContent>
</Dialog>
)
}Tryb kontrolowany
Okna otwierane z pozycji menu, skrótem klawiszowym albo po zakończeniu operacji asynchronicznej nie mają własnego wyzwalacza. Wyrenderuj okno poza menu i steruj nim za pomocą open i onOpenChange.
"use client"
import * as React from "react"
import { MoreHorizontalIcon, PencilIcon, Trash2Icon } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Field, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
export default function DialogControlled() {
const [open, setOpen] = React.useState(false)
const [name, setName] = React.useState("Planowanie Q3")
const [draft, setDraft] = React.useState(name)
function rename(event: React.FormEvent) {
event.preventDefault()
setName(draft.trim() || name)
setOpen(false)
}
return (
<div className="flex items-center gap-3 rounded-xl border bg-card py-2 pr-2 pl-4 shadow-raised">
<span className="text-sm font-medium">{name}</span>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="ghost"
size="icon-sm"
aria-label="Akcje dokumentu"
/>
}
>
<MoreHorizontalIcon />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuGroup>
<DropdownMenuItem
onClick={() => {
setDraft(name)
setOpen(true)
}}
>
<PencilIcon />
Zmień nazwę…
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem variant="destructive">
<Trash2Icon />
Usuń
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
{/* Opened from a menu item, so it has no trigger of its own: control it. */}
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-md">
<form onSubmit={rename} className="flex flex-col gap-5">
<DialogHeader>
<DialogTitle>Zmień nazwę dokumentu</DialogTitle>
<DialogDescription>
Nowa nazwa będzie widoczna dla wszystkich, którzy mają dostęp.
</DialogDescription>
</DialogHeader>
<Field>
<FieldLabel htmlFor="document-name">Nazwa</FieldLabel>
<Input
id="document-name"
value={draft}
onChange={(event) => setDraft(event.target.value)}
/>
</Field>
<DialogFooter>
<DialogClose render={<Button variant="outline" />}>
Anuluj
</DialogClose>
<Button type="submit">Zmień nazwę</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
)
}const [open, setOpen] = React.useState(false)
<DropdownMenuItem onClick={() => setOpen(true)}>Rename…</DropdownMenuItem>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>{/* … */}</DialogContent>
</Dialog>Stopka
DialogFooter to pas z delikatnie zabarwionym tłem, przylegający do dolnej krawędzi. Akcje umieszczaj po prawej, dodatkowy kontekst po lewej, a gdy okno potrzebuje tylko drogi wyjścia, użyj showCloseButton.
"use client"
import * as React from "react"
import { CheckIcon, CopyIcon, LinkIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Field, FieldLabel } from "@/components/ui/field"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@/components/ui/input-group"
const url = "https://prfct.dev/share/7f3a-q3-roadmap"
export default function DialogShare() {
const [copied, setCopied] = React.useState(false)
React.useEffect(() => {
if (!copied) return
const timer = setTimeout(() => setCopied(false), 1600)
return () => clearTimeout(timer)
}, [copied])
async function copy() {
try {
await navigator.clipboard.writeText(url)
setCopied(true)
} catch {
// The clipboard can be denied; the link stays in the field to select.
}
}
return (
<Dialog>
<DialogTrigger render={<Button variant="outline" />}>
<LinkIcon data-icon="inline-start" />
Udostępnij
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Udostępnij link</DialogTitle>
<DialogDescription>
Każdy, kto ma link, może wyświetlić tę roadmapę.
</DialogDescription>
</DialogHeader>
<Field>
<FieldLabel htmlFor="share-link" className="sr-only">
Link
</FieldLabel>
<InputGroup>
<InputGroupInput id="share-link" defaultValue={url} readOnly />
<InputGroupAddon align="inline-end">
<InputGroupButton
size="icon-xs"
aria-label={copied ? "Skopiowano" : "Kopiuj link"}
onClick={copy}
>
{copied ? (
<CheckIcon className="text-success-11" />
) : (
<CopyIcon />
)}
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
</Field>
<DialogFooter showCloseButton className="sm:justify-between">
<span className="self-center text-xs text-muted-foreground">
Link wygaśnie za 30 dni
</span>
</DialogFooter>
</DialogContent>
</Dialog>
)
}Szerokość
Domyślna maksymalna szerokość to max-w-lg (32 rem) — w sam raz dla formularza. Dla treści pomocniczych, np. listy skrótów, poszerz okno klasą max-w-*, ale zachowaj boczny margines: okno ma zawsze najwyżej calc(100% - 2rem) szerokości.
import { KeyboardIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Kbd, KbdGroup } from "@/components/ui/kbd"
const groups = [
{
title: "Nawigacja",
shortcuts: [
{ label: "Otwórz paletę poleceń", keys: ["⌘", "K"] },
{ label: "Przejdź do skrzynki odbiorczej", keys: ["G", "I"] },
{ label: "Przejdź do projektów", keys: ["G", "P"] },
{ label: "Szukaj", keys: ["/"] },
],
},
{
title: "Edycja",
shortcuts: [
{ label: "Utwórz zgłoszenie", keys: ["C"] },
{ label: "Przypisz do mnie", keys: ["I"] },
{ label: "Zmień status", keys: ["S"] },
{ label: "Przełącz motyw", keys: ["D"] },
],
},
]
export default function DialogWide() {
return (
<Dialog>
<DialogTrigger render={<Button variant="ghost" />}>
<KeyboardIcon data-icon="inline-start" />
Skróty
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Skróty klawiszowe</DialogTitle>
<DialogDescription>
Naciśnij <Kbd>?</Kbd> w dowolnym miejscu, aby otworzyć tę listę.
</DialogDescription>
</DialogHeader>
<div className="grid gap-8 sm:grid-cols-2">
{groups.map((group) => (
<section key={group.title} className="flex flex-col gap-2">
<h3 className="text-overline text-muted-foreground">
{group.title}
</h3>
<ul className="flex flex-col">
{group.shortcuts.map((shortcut) => (
<li
key={shortcut.label}
className="flex items-center justify-between gap-4 border-b py-2 text-sm last:border-0"
>
<span className="text-foreground">{shortcut.label}</span>
<KbdGroup>
{shortcut.keys.map((key) => (
<Kbd key={key}>{key}</Kbd>
))}
</KbdGroup>
</li>
))}
</ul>
</section>
))}
</div>
</DialogContent>
</Dialog>
)
}Wytyczne
Kiedy używać
- Aby zebrać niewielką ilość danych bez opuszczania strony: zmiana nazwy, zaproszenie, edycja kilku pól.
- Aby pokazać informację, którą trzeba potwierdzić, zanim przejdzie się dalej.
- Aby potwierdzić zadanie rozpoczęte na bieżącej stronie, z kontekstem wystarczającym do podjęcia decyzji.
Kiedy nie używać
- Do potwierdzania akcji nieodwracalnych lub niszczących dane — użyj Alert Dialog, którego nie da się zamknąć kliknięciem obok.
- Do długich formularzy, procesów wieloetapowych i wszystkiego, co trzeba porównywać z zawartością strony — użyj Sheet albo osobnej strony.
- Do komunikatu o powodzeniu akcji — użyj Toast. Przerywanie pracy tylko po to, żeby powiedzieć, że wszystko poszło dobrze, to podatek od każdego zadania.
- Do kontrolek kontekstowych powiązanych z jednym elementem — użyj Popover.
Wybór nakładki
Każda nakładka to kompromis między skupieniem uwagi a zachowaniem kontekstu. Wybierz najlżejszą, która wystarczy do zadania.
Jak pisać
Tytuł okna powinien mówić, co ono robi, a nie zadawać pytanie, które potem powtarzasz: Edytuj profil, Zaproś do zespołu. Główny przycisk nazwij od rezultatu i powtórz kluczowy rzeczownik, jeśli to pomaga: Zapisz zmiany, Wyślij 3 zaproszenia. Opis jest opcjonalny, ale zwykle warto poświęcić mu jedno zdanie kontekstu.
Jedno okno naraz
Nigdy nie otwieraj okna dialogowego na innym oknie dialogowym. Jeśli zadanie wymaga drugiego kroku, podmień zawartość pierwszego okna albo przenieś cały proces na osobną stronę.
Dostępność
Okno realizuje wzorzec WAI-ARIA dialog. Base UI renderuje je z role="dialog", nadaje mu nazwę z DialogTitle i opis z DialogDescription. Nie ustawia aria-modal: gdy okno modalne jest otwarte, wszystko poza nim dostaje zamiast tego aria-hidden.
| Klawisz | Działanie |
|---|---|
EnterSpacja | Na wyzwalaczu: otwiera okno i przenosi do niego fokus. |
Tab | Przenosi fokus do następnego elementu, który może go przyjąć. Fokus nie wychodzi poza okno. |
ShiftTab | Przenosi fokus do poprzedniego elementu, który może go przyjąć; na początku przeskakuje na koniec. |
Esc | Zamyka okno i przywraca fokus do elementu, który je otworzył. |
- Zawsze dodawaj tytuł.
DialogTitlenadaje oknu dostępną nazwę. Jeśli projekt nie przewiduje widocznego tytułu, zostaw go i dodajclassName="sr-only". - Fokus. Po otwarciu fokus trafia do pierwszego elementu, który może go przyjąć — a przy otwarciu dotykiem do samego okna, żeby nie wysuwała się klawiatura ekranowa. Zmienisz to propsami
initialFocusifinalFocusnaDialogContent. - Reszta strony jest nieaktywna. Gdy okno modalne jest otwarte, przewijanie strony jest zablokowane, a treść poza oknem jest nieosiągalna dla myszy, klawiatury i czytnika ekranu.
- Droga wyjścia. Zostaw przycisk zamykania (
showCloseButton, domyślnie włączony) albo przyciskDialogClosew stopce. Osoby korzystające z czytnika ekranu na urządzeniu dotykowym nie mogą nacisnąć Esc. - Ruch. Okno pojawia się z przenikaniem i skalowaniem w ciągu 320 ms, z krzywą przejścia
enter, a znika szybciej, niż się pojawiło. Przy ograniczonym ruchu pojawia się natychmiast.
Dokumentacja API
Dialog
Komponent główny. Nie renderuje żadnego elementu. Przyjmuje wszystkie propsy Dialog.Root z Base UI.
DialogTrigger
Otwiera okno. Renderuje <button>; użyj render, żeby wyrenderować Button z prfct.
DialogContent
Renderuje portal, przyciemnione tło i samo okno. Dwa komponenty, z których korzysta — DialogPortal i DialogOverlay — też są eksportowane, na wypadek układów, w których rozmieszczasz je samodzielnie.
DialogHeader, DialogTitle, DialogDescription
DialogHeader układa tytuł i opis jeden pod drugim i rezerwuje miejsce na przycisk zamykania. DialogTitle renderuje <h2> i nadaje oknu nazwę; DialogDescription renderuje <p> i je opisuje.
DialogFooter
Pas z delikatnie zabarwionym tłem, przylegający do dolnej krawędzi okna. Na małych ekranach układa akcje jedna pod drugą, od sm wzwyż wyrównuje je do prawej.
DialogClose
Zamyka okno. Renderuje <button>; użyj render, żeby wyrenderować Button z prfct.