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.
Dialog (confirm)
Section titled “Dialog (confirm)”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).
Lightbox (image preview)
Section titled “Lightbox (image preview)”Pass src and alt; wire a thumbnail or framed screenshot as the trigger.
Regenerate: cd samples/apps/showcase && bun run screenshot → screenshots/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).
Apex screenshots
Section titled “Apex screenshots”Hero and showcase sections that use .shot-frame get click-to-enlarge automatically when you call renderApexPage — no app wiring.
Regenerate: cd samples/recipes/apex-landing && bun run screenshot → screenshots/lightbox.png. See Build a marketing landing page.
When to use which
Section titled “When to use which”| Need | Primitive |
|---|---|
| ”Are you sure?” before delete | Dialog via usePrimitives() or row-action confirm |
| Product screenshot enlarged | Lightbox in React apps; .shot-frame on Apex |
| Form inside a modal | Dialog with children between description and buttons |