Skip to content

Dialog and Lightbox overlays

Kumiko ships two overlay primitives for different jobs:

  • Dialog — confirm or cancel a destructive or async action (row actions, delete flows).
  • Lightbox — show an image at full size; no confirm/cancel footer.

Both web implementations share an internal ModalShell (Radix: focus trap, Escape, backdrop click). Apex marketing pages use a separate vanilla <dialog> for .shot-frame screenshots — no React on those static landings.

Open state lives in your component (open + onOpenChange). onConfirm may be async; the confirm button shows a spinner until the promise settles, then the dialog closes.

import { usePrimitives } from "@cosmicdrift/kumiko-renderer";
import { type ReactNode, useState } from "react";
import { DemoPage, DemoSection } from "../components/page";
export function DialogDemo(): ReactNode {
const { Button, Dialog, Lightbox, Text } = usePrimitives();
const [defaultOpen, setDefaultOpen] = useState(false);
const [dangerOpen, setDangerOpen] = useState(false);
const [asyncOpen, setAsyncOpen] = useState(false);
const [lightboxOpen, setLightboxOpen] = useState(false);
const [confirmedAction, setConfirmedAction] = useState<string>("(noch nichts bestätigt)");
return (
<DemoPage
title="Dialog & Lightbox"
description="Dialog für Bestätigungen; Lightbox für Vollbild-Bildvorschau. Beide teilen dieselbe Radix-Overlay-Shell (Focus-Trap, Esc, Backdrop-Click)."
>
<DemoSection title="Default-Variant">
<div className="flex flex-wrap items-center gap-2">
<Button onClick={() => setDefaultOpen(true)}>Dialog öffnen</Button>
<Text variant="small">Letzte Aktion: {confirmedAction}</Text>
</div>
<Dialog
open={defaultOpen}
onOpenChange={setDefaultOpen}
title="Bist du dir sicher?"
description="Diese Aktion sendet eine E-Mail an alle Kunden. Bitte vorher prüfen."
onConfirm={() => setConfirmedAction("Default-Confirm")}
testId="dialog-default"
/>
</DemoSection>
<DemoSection title="Danger-Variant">
<Button variant="danger" onClick={() => setDangerOpen(true)}>
Eintrag löschen…
</Button>
<Dialog
open={dangerOpen}
onOpenChange={setDangerOpen}
title="Wirklich löschen?"
description="Der Datensatz wird unwiderruflich entfernt. Diese Aktion kann nicht rückgängig gemacht werden."
confirmLabel="Löschen"
variant="danger"
onConfirm={() => setConfirmedAction("Danger-Confirm (delete)")}
testId="dialog-danger"
/>
</DemoSection>
<DemoSection title="Async onConfirm (Spinner)">
<Button onClick={() => setAsyncOpen(true)}>Async-Dialog öffnen</Button>
<Dialog
open={asyncOpen}
onOpenChange={setAsyncOpen}
title="Speichern dauert kurz"
description="onConfirm ist async (1.5s). Confirm-Button zeigt während der Promise-Resolution einen Spinner; Dialog schließt automatisch danach."
onConfirm={async () => {
await new Promise((r) => setTimeout(r, 1500));
setConfirmedAction("Async-Confirm (1.5s)");
}}
testId="dialog-async"
/>
</DemoSection>
<DemoSection title="Lightbox">
{/* kumiko-lint-ignore primitives-discipline clickable image thumbnail (Lightbox-Trigger) — kein Text-Button, <Button> kann kein Full-Bleed-<img> wrappen */}
<button
type="button"
onClick={() => setLightboxOpen(true)}
className="cursor-zoom-in overflow-hidden rounded-lg border border-border shadow-sm"
data-testid="lightbox-trigger"
>
<img
src="/screenshots/hero-app.png"
alt="Tasklane planning board — click to enlarge"
className="block h-auto w-full max-w-md"
/>
</button>
<Text variant="small">Klick auf das Bild öffnet die Vollbild-Vorschau.</Text>
<Lightbox
open={lightboxOpen}
onOpenChange={setLightboxOpen}
src="/screenshots/hero-app.png"
alt="Tasklane planning board — click to enlarge"
testId="lightbox-demo"
/>
</DemoSection>
</DemoPage>
);
}

Try it: Showcase → Primitives → Dialog & Lightbox (port 4175).

Pass src and alt; wire a thumbnail or framed screenshot as the trigger.

React Lightbox open — blurred backdrop, enlarged image, close button

Regenerate: cd samples/apps/showcase && bun run screenshotscreenshots/lightbox.png (or docs CI via gen-feature-screenshots).

import { usePrimitives } from "@cosmicdrift/kumiko-renderer";
import { type ReactNode, useState } from "react";
import { DemoPage, DemoSection } from "../components/page";
export function DialogDemo(): ReactNode {
const { Button, Dialog, Lightbox, Text } = usePrimitives();
const [defaultOpen, setDefaultOpen] = useState(false);
const [dangerOpen, setDangerOpen] = useState(false);
const [asyncOpen, setAsyncOpen] = useState(false);
const [lightboxOpen, setLightboxOpen] = useState(false);
const [confirmedAction, setConfirmedAction] = useState<string>("(noch nichts bestätigt)");
return (
<DemoPage
title="Dialog & Lightbox"
description="Dialog für Bestätigungen; Lightbox für Vollbild-Bildvorschau. Beide teilen dieselbe Radix-Overlay-Shell (Focus-Trap, Esc, Backdrop-Click)."
>
<DemoSection title="Default-Variant">
<div className="flex flex-wrap items-center gap-2">
<Button onClick={() => setDefaultOpen(true)}>Dialog öffnen</Button>
<Text variant="small">Letzte Aktion: {confirmedAction}</Text>
</div>
<Dialog
open={defaultOpen}
onOpenChange={setDefaultOpen}
title="Bist du dir sicher?"
description="Diese Aktion sendet eine E-Mail an alle Kunden. Bitte vorher prüfen."
onConfirm={() => setConfirmedAction("Default-Confirm")}
testId="dialog-default"
/>
</DemoSection>
<DemoSection title="Danger-Variant">
<Button variant="danger" onClick={() => setDangerOpen(true)}>
Eintrag löschen…
</Button>
<Dialog
open={dangerOpen}
onOpenChange={setDangerOpen}
title="Wirklich löschen?"
description="Der Datensatz wird unwiderruflich entfernt. Diese Aktion kann nicht rückgängig gemacht werden."
confirmLabel="Löschen"
variant="danger"
onConfirm={() => setConfirmedAction("Danger-Confirm (delete)")}
testId="dialog-danger"
/>
</DemoSection>
<DemoSection title="Async onConfirm (Spinner)">
<Button onClick={() => setAsyncOpen(true)}>Async-Dialog öffnen</Button>
<Dialog
open={asyncOpen}
onOpenChange={setAsyncOpen}
title="Speichern dauert kurz"
description="onConfirm ist async (1.5s). Confirm-Button zeigt während der Promise-Resolution einen Spinner; Dialog schließt automatisch danach."
onConfirm={async () => {
await new Promise((r) => setTimeout(r, 1500));
setConfirmedAction("Async-Confirm (1.5s)");
}}
testId="dialog-async"
/>
</DemoSection>
<DemoSection title="Lightbox">
{/* kumiko-lint-ignore primitives-discipline clickable image thumbnail (Lightbox-Trigger) — kein Text-Button, <Button> kann kein Full-Bleed-<img> wrappen */}
<button
type="button"
onClick={() => setLightboxOpen(true)}
className="cursor-zoom-in overflow-hidden rounded-lg border border-border shadow-sm"
data-testid="lightbox-trigger"
>
<img
src="/screenshots/hero-app.png"
alt="Tasklane planning board — click to enlarge"
className="block h-auto w-full max-w-md"
/>
</button>
<Text variant="small">Klick auf das Bild öffnet die Vollbild-Vorschau.</Text>
<Lightbox
open={lightboxOpen}
onOpenChange={setLightboxOpen}
src="/screenshots/hero-app.png"
alt="Tasklane planning board — click to enlarge"
testId="lightbox-demo"
/>
</DemoSection>
</DemoPage>
);
}

Try it: Showcase → Primitives → Dialog & Lightbox (port 4175).

Hero and showcase sections that use .shot-frame get click-to-enlarge automatically when you call renderApexPage — no app wiring.

Apex lightbox open — native dialog over the landing page

Regenerate: cd samples/recipes/apex-landing && bun run screenshotscreenshots/lightbox.png. See Build a marketing landing page.

NeedPrimitive
”Are you sure?” before deleteDialog via usePrimitives() or row-action confirm
Product screenshot enlargedLightbox in React apps; .shot-frame on Apex
Form inside a modalDialog with children between description and buttons