stream/ui
Browse the library

Comic Alert

A comic-book alert: a speech bubble pointing at a starburst, with a shouty title and a stamped sound effect.

betaComicAlert.tsx

Examples

Tones

Each tone has its icon, colors, and sound effect: HEY!, YES!, UH-OH!, YIKES! Warnings and errors shake the burst.

Heads up

A new version is rolling out tonight.

Changes saved

Your profile is up to date.
comic-alert/tones.tsx
import { ComicAlert } from "@streamuilib/react";

export default function ComicAlertTones() {
  return (
    <div className="flex w-full max-w-xl flex-col gap-7 pt-3">
      <ComicAlert tone="info" title="Heads up">
        A new version is rolling out tonight.
      </ComicAlert>
      <ComicAlert tone="success" title="Changes saved">
        Your profile is up to date.
      </ComicAlert>
      <ComicAlert tone="warning" title="Almost out of seats">
        You are using 9 of 10 seats on your plan.
      </ComicAlert>
      <ComicAlert tone="danger" title="Payment failed">
        We could not charge your card ending in 4242.
      </ComicAlert>
    </div>
  );
}

Actions, icons, and sound effects

Add actions, swap the icon, set your own sound effect or turn it off. Replay remounts it to see the pop-in again.

Launch day

Your own icon, and no sound effect.
comic-alert/actions.tsx
"use client";

import { useState } from "react";
import { Rocket } from "lucide-react";
import { ComicAlert, ComicButton } from "@streamuilib/react";

export default function ComicAlertActions() {
  const [n, setN] = useState(0);
  return (
    <div className="flex w-full max-w-xl flex-col items-start gap-7 pt-3">
      {/* A new key remounts it, so it pops in again. */}
      <ComicAlert
        key={n}
        tone="warning"
        title="Deploy blocked"
        sfx="Whoa!"
        action={
          <>
            <ComicButton size="sm" tone="orange" pop="FIXED!">
              Fix it
            </ComicButton>
            <ComicButton size="sm" tone="paper" pop={false} onClick={() => setN((x) => x + 1)}>
              Replay
            </ComicButton>
          </>
        }
      >
        A required check is failing on main.
      </ComicAlert>
      <ComicAlert tone="info" icon={<Rocket className="size-6" strokeWidth={2.5} />} title="Launch day" sfx={false}>
        Your own icon, and no sound effect.
      </ComicAlert>
    </div>
  );
}

Installation

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

<ComicAlert tone="success" title="Changes saved">Your profile is up to date.</ComicAlert>

API

PropTypeDefaultDescription
tone"info" | "success" | "warning" | "danger""info"Colors, icon, sound effect, and urgency.
titleReactNode—The shouty heading in the bubble.
iconReactNode | null—Replaces the tone's icon in the starburst; null drops the starburst and tail.
actionReactNode—Buttons or links under the message.
sfxReactNode | falseby toneThe stamped sound effect; false for none.
...propsOmit<ComponentProps<"div">, "title">—Native attributes; pass role to override the default.

Accessibility

  • Warnings and errors use role="alert" so they're announced right away; info and success use role="status", the same as Alert.
  • The starburst, tail, and sound effect are decorative and hidden; the title and message are the content.
  • The pop-in and shake stop when the user prefers reduced motion.

Source

packages/react/src/ComicAlert.tsx
import type { ComponentProps, ReactNode } from "react";
import { CircleAlert, CircleCheck, Info, TriangleAlert } from "lucide-react";
import { cn } from "./cn";
import type { AlertTone } from "./Alert";
import { ComicBadge } from "./ComicBadge";

const tones: Record<AlertTone, { bubble: string; tile: "blue" | "lime" | "orange" | "pink"; sticker: string; icon: typeof Info; sfx: string }> = {
  info: { bubble: "bg-blue-soft", tile: "blue", sticker: "bg-blue", icon: Info, sfx: "Hey!" },
  success: { bubble: "bg-lime-soft", tile: "lime", sticker: "bg-lime", icon: CircleCheck, sfx: "Yes!" },
  warning: { bubble: "bg-orange-soft", tile: "orange", sticker: "bg-orange", icon: TriangleAlert, sfx: "Uh-oh!" },
  danger: { bubble: "bg-pink-soft", tile: "pink", sticker: "bg-pink", icon: CircleAlert, sfx: "Yikes!" },
};

export type ComicAlertProps = Omit<ComponentProps<"div">, "title"> & {
  tone?: AlertTone;
  title?: ReactNode;
  /** Replaces the tone's icon in the starburst. Pass `null` for no starburst. */
  icon?: ReactNode;
  /** Buttons or links shown under the message. */
  action?: ReactNode;
  /** The stamped sound effect. Defaults by tone (HEY!, YES!, UH-OH!, YIKES!); false for none. */
  sfx?: ReactNode | false;
};

/**
 * A comic-book alert: a speech bubble whose tail points at a starburst with
 * the tone's icon, halftone dots, a shouty title, and a stamped sound effect.
 * It pops in when it appears; warnings and errors shake the burst a few
 * times. Warnings and errors use role="alert", info and success the politer
 * role="status", like Alert. Motion stops for reduced motion.
 */
export function ComicAlert({ tone = "info", title, icon, action, sfx, className, children, role, ...props }: ComicAlertProps) {
  const t = tones[tone];
  const urgent = tone === "warning" || tone === "danger";
  const glyph = icon === undefined ? <t.icon className="size-6" strokeWidth={2.5} /> : icon;
  const stamp = sfx === undefined ? t.sfx : sfx;

  return (
    <div
      role={role ?? (urgent ? "alert" : "status")}
      data-urgent={urgent || undefined}
      className={cn("sui-comic-alert flex items-center gap-1 text-ink", className)}
      {...props}
    >
      {glyph && (
        <span aria-hidden className="sui-comic-alert-burst relative z-10 shrink-0">
          <ComicBadge shape="burst" tone={t.tile} size="md" tilt={-8}>
            {glyph}
          </ComicBadge>
        </span>
      )}

      <div className="sui-comic-alert-bubble relative min-w-0 flex-1">
        {/* The tail: an ink triangle with the bubble's fill on top, pointing at the burst. */}
        {glyph && (
          <>
            <span aria-hidden className="absolute top-1/2 -left-[15px] block size-0 -translate-y-1/2 border-y-[11px] border-r-[16px] border-y-transparent border-r-ink" />
            <span
              aria-hidden
              className={cn("absolute top-1/2 -left-[9px] z-10 block size-0 -translate-y-1/2 border-y-[7px] border-r-[11px] border-y-transparent", {
                "border-r-blue-soft": tone === "info",
                "border-r-lime-soft": tone === "success",
                "border-r-orange-soft": tone === "warning",
                "border-r-pink-soft": tone === "danger",
              })}
            />
          </>
        )}
        <div
          className={cn("relative flex flex-col gap-1 rounded-2xl border-[3px] border-ink px-5 py-4 shadow-[5px_5px_0_var(--ink)]", t.bubble)}
          style={{
            backgroundImage: "radial-gradient(color-mix(in oklab, var(--ink) 13%, transparent) 1.2px, transparent 1.6px)",
            backgroundSize: "8px 8px",
          }}
        >
          {stamp && (
            <span
              aria-hidden
              className={cn(
                "sui-comic-alert-stamp absolute -top-3.5 right-4 rotate-6 border-[3px] border-ink px-2 py-0.5 font-mono text-xs font-bold tracking-wide uppercase text-on-bright shadow-[2px_2px_0_var(--ink)]",
                t.sticker,
              )}
            >
              {stamp}
            </span>
          )}
          {title && <p className="pr-16 font-mono text-sm font-bold tracking-wide uppercase">{title}</p>}
          {children && <div className="text-sm leading-relaxed">{children}</div>}
          {action && <div className="mt-2 flex flex-wrap gap-3">{action}</div>}
        </div>
      </div>
    </div>
  );
}