Skip to content

Dialog and Lightbox overlays

Kumiko provides two overlay primitives for different interactions:

  • Dialog confirms or cancels an action. It can also host a compact form.
  • Lightbox shows an image at full size and has no confirm footer.

The React implementations use the shared modal shell for focus handling, Escape, and backdrop clicks. Apex marketing pages use a separate native <dialog> for .shot-frame screenshots.

Keep open in the component that decides when the dialog appears. Pass onOpenChange so cancel and close events update that state. onConfirm may be async; the primitive shows its loading state and closes after the promise resolves.

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) — not a text button, <Button> cannot wrap a full-bleed <img> */}
<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>
);
}

The runnable example includes a neutral confirmation, a destructive confirmation, an async confirmation, and a Lightbox trigger. Try it in the Showcase under Primitives → Dialog & Lightbox (port 4175).

Pass the image src and accessible alt text. The app owns the thumbnail and its open state:

const [open, setOpen] = useState(false);
<button type="button" onClick={() => setOpen(true)}>
<img src="/screenshots/apex/landing.png" alt="Apex landing page" />
</button>
<Lightbox
open={open}
onOpenChange={setOpen}
src="/screenshots/apex/landing.png"
alt="Apex landing page"
/>

The Showcase screenshot is stored at samples/apps/showcase/screenshots/lightbox.png. Regenerate it with:

Terminal window
cd samples/apps/showcase
bun run screenshot

renderApexPage adds click-to-enlarge behavior to hero and showcase sections that use .shot-frame. This path does not use React or the Lightbox primitive.

The runnable Apex example stores its screenshot at samples/recipes/apex-landing/screenshots/lightbox.png.

NeedPrimitive
Confirm before delete or another destructive actionDialog with variant="danger"
Confirm a neutral or async actionDialog
Show a product screenshot at full sizeLightbox in a React app
Put an existing form with its own buttons into an overlayModal
Enlarge an Apex .shot-frame screenshotApex’s built-in lightbox