stream/ui
Browse the library

Comic Dialog

A hand-inked modal that slams in over a halftone backdrop, with a caption-box title and a sound effect.

betaComicDialog.tsx

Examples

Confirm

A sound effect on the corner, and comic buttons in the footer.

Delete this project?

This removes all deployments, and it can't be undone.

comic-dialog/basic.tsx
"use client";

import { useState } from "react";
import { ComicButton, ComicDialog } from "@streamuilib/react";

export default function ComicDialogBasic() {
  const [open, setOpen] = useState(false);

  return (
    <ComicDialog
      open={open}
      onOpenChange={setOpen}
      title="Delete this project?"
      description="This removes all deployments, and it can't be undone."
      sfx="Gulp!"
      trigger={
        <ComicButton tone="pink" pop={false}>
          Delete project
        </ComicButton>
      }
      footer={
        <>
          <ComicButton tone="paper" pop={false} onClick={() => setOpen(false)}>
            Cancel
          </ComicButton>
          <ComicButton tone="pink" pop="KABOOM!" onClick={() => setTimeout(() => setOpen(false), 450)}>
            Delete
          </ComicButton>
        </>
      }
    />
  );
}

With a form

A form with method="dialog" closes it on submit.

Join the team

Pick a hero name. You can change it later.

comic-dialog/form.tsx
"use client";

import { useState } from "react";
import { ComicButton, ComicDialog, ComicInput } from "@streamuilib/react";

export default function ComicDialogForm() {
  const [open, setOpen] = useState(false);
  const [name, setName] = useState("");

  return (
    <div className="flex flex-col items-center gap-3">
      <ComicDialog
        open={open}
        onOpenChange={setOpen}
        title="Join the team"
        description="Pick a hero name. You can change it later."
        trigger={<ComicButton pop={false}>Sign me up</ComicButton>}
      >
        <form
          method="dialog"
          className="flex flex-col gap-4"
          onSubmit={(e) => {
            setName(new FormData(e.currentTarget).get("hero") as string);
          }}
        >
          <label className="flex flex-col gap-2">
            <span className="font-bold">Hero name</span>
            <ComicInput name="hero" required placeholder="Captain Brutal" autoFocus />
          </label>
          <ComicButton type="submit" className="self-end" pop="WELCOME!">
            Join
          </ComicButton>
        </form>
      </ComicDialog>
      {name && <p className="font-mono text-sm">Welcome aboard, {name}!</p>}
    </div>
  );
}

Installation

bun add @streamuilib/react
Open in v0 (opens in a new tab)
usage.tsx
import { ComicButton, ComicDialog } from "@streamuilib/react";

<ComicDialog
  title="Delete this project?"
  sfx="Gulp!"
  trigger={<ComicButton>Delete project</ComicButton>}
/>

API

PropTypeDefaultDescription
titleReactNode—Heading on the caption box; also the dialog's accessible name. Required.
descriptionReactNode—Supporting text, linked with aria-describedby.
sfxReactNode—A starburst sound effect on the top-right corner.
triggerReactElement—An element that opens the dialog when clicked.
open / defaultOpen / onOpenChangeboolean / boolean / (open) => void—Controlled or uncontrolled open state.
footerReactNode—Buttons along the bottom.
dismissiblebooleantrueClose when the backdrop is clicked.

Accessibility

  • Native <dialog> opened with showModal(), the same as Dialog: focus moves in and is trapped, Esc closes, and focus returns to the trigger.
  • The rest of the page is inert while it's open.
  • The close button is labelled; the sound effect is decorative and hidden.
  • The slam-in stops when the user prefers reduced motion.

Source

packages/react/src/ComicDialog.tsx
"use client";

import { cloneElement, isValidElement, useEffect, useId, useRef, useState, type MouseEvent, type ReactNode } from "react";
import { X } from "lucide-react";
import { cn } from "./cn";
import { halftone, sketchy } from "./comic";
import { ComicBadge } from "./ComicBadge";
import type { DialogProps } from "./Dialog";

export type ComicDialogProps = DialogProps & {
  /** A starburst sound effect stuck on the top-right corner ("PSST!"). */
  sfx?: ReactNode;
};

/**
 * A comic-book modal: a hand-inked panel that slams in over a halftone
 * backdrop, its title on a tilted caption box, a starburst close button, and
 * an optional sound effect. Built on native <dialog>.showModal(), like
 * Dialog: focus moves in and is trapped, Esc closes, focus returns to the
 * trigger, and the rest of the page is inert. Motion stops for reduced motion.
 */
export function ComicDialog({
  title,
  description,
  children,
  footer,
  trigger,
  open,
  defaultOpen = false,
  onOpenChange,
  dismissible = true,
  sfx,
  className,
}: ComicDialogProps) {
  const [internal, setInternal] = useState(defaultOpen);
  const isOpen = open ?? internal;
  const ref = useRef<HTMLDialogElement>(null);
  const titleId = useId();
  const descriptionId = useId();

  const setOpen = (next: boolean) => {
    setInternal(next);
    onOpenChange?.(next);
  };

  useEffect(() => {
    const dialog = ref.current;
    if (!dialog) return;
    if (isOpen && !dialog.open) dialog.showModal();
    if (!isOpen && dialog.open) dialog.close();
  }, [isOpen]);

  return (
    <>
      {isValidElement(trigger) &&
        cloneElement(trigger, {
          "aria-haspopup": "dialog",
          "aria-expanded": isOpen,
          onClick: (e: MouseEvent) => {
            trigger.props.onClick?.(e);
            setOpen(true);
          },
        } as Record<string, unknown>)}
      <dialog
        ref={ref}
        aria-labelledby={titleId}
        aria-describedby={description ? descriptionId : undefined}
        onClose={() => isOpen && setOpen(false)}
        onClick={(e) => {
          if (dismissible && e.target === e.currentTarget) setOpen(false);
        }}
        className="sui-comic-dialog fixed inset-0 m-0 h-dvh max-h-none w-full max-w-none place-items-center bg-transparent p-6 open:grid"
      >
        <div
          className={cn(
            "sui-comic-dialog-panel relative flex w-full max-w-lg flex-col border-[3px] border-ink bg-paper-raised text-ink shadow-[8px_8px_0_var(--ink)]",
            sketchy,
            className,
          )}
        >
          {sfx && (
            <span aria-hidden className="sui-comic-dialog-sfx absolute -top-9 -right-7 z-10">
              <ComicBadge shape="burst" tone="pink" size="sm" tilt={12}>
                {sfx}
              </ComicBadge>
            </span>
          )}
          <div className="flex items-start justify-between gap-4 border-b-[3px] border-ink px-5 pt-4 pb-4" style={halftone(10)}>
            <div className="flex flex-col items-start gap-2">
              <h2
                id={titleId}
                className="-mt-8 -ml-8 -rotate-2 border-[3px] border-ink bg-lime px-3 py-1 font-mono text-sm leading-tight font-bold tracking-wide uppercase text-on-bright shadow-[3px_3px_0_var(--ink)]"
              >
                {title}
              </h2>
              {description && (
                <p id={descriptionId} className="text-sm leading-relaxed text-ink-muted">
                  {description}
                </p>
              )}
            </div>
            <button
              type="button"
              aria-label="Close"
              onClick={() => setOpen(false)}
              className="sui-comic-dialog-close relative grid size-10 shrink-0 cursor-pointer place-items-center focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-blue"
            >
              <svg aria-hidden viewBox="0 0 100 100" overflow="visible" className="absolute inset-0 size-full">
                <path d="M50 3C77 2 97 20 96 47C95 74 76 97 49 96C22 95 3 77 4 50C5 23 23 4 50 3Z" className="fill-paper-raised stroke-ink" strokeWidth={8} />
              </svg>
              <X aria-hidden className="relative size-4.5" strokeWidth={3} />
            </button>
          </div>
          {children && <div className="p-5 leading-relaxed">{children}</div>}
          {footer && (
            <div className={cn("flex flex-wrap justify-end gap-3 border-t-[3px] border-dashed border-ink p-5", !children && "border-t-0")}>
              {footer}
            </div>
          )}
        </div>
      </dialog>
    </>
  );
}