stream/ui
Browse the library

Comic Card

A comic-book panel: a skewed card with halftone dots, a narration caption, and a starburst sound effect.

betaComicCard.tsx

Examples

Captions and sound effects

Hover a panel: it lifts and its sound effect jumps. CardTitle and CardDescription work inside.

Meanwhile, in prod…

Kapow!

Deploys ship in seconds

Push to main and every pull request gets a live URL before the coffee is ready.

Chapter 2

Alerts that mean it

Get pinged when a metric really moves, not every time it twitches.

New!
comic-card/basic.tsx
import { CardDescription, CardTitle, ComicBadge, ComicButton, ComicCard } from "@streamuilib/react";

export default function ComicCardBasic() {
  return (
    <div className="grid w-full max-w-3xl grid-cols-1 gap-10 pt-6 sm:grid-cols-2">
      <ComicCard caption="Meanwhile, in prod…" sfx="Kapow!">
        <CardTitle>Deploys ship in seconds</CardTitle>
        <CardDescription>Push to main and every pull request gets a live URL before the coffee is ready.</CardDescription>
      </ComicCard>
      <ComicCard tone="pink" caption="Chapter 2" tilt={1.5}>
        <CardTitle>Alerts that mean it</CardTitle>
        <CardDescription>Get pinged when a metric really moves, not every time it twitches.</CardDescription>
        <div className="mt-3 flex items-center gap-3">
          <ComicButton size="sm" tone="lime">
            Turn on
          </ComicButton>
          <ComicBadge shape="bubble" tone="paper" size="sm" tilt={-4}>
            New!
          </ComicBadge>
        </div>
      </ComicCard>
    </div>
  );
}

Tones

Panel 1

paper

A soft panel, with its caption in the matching bright.

Panel 2

lime

A soft panel, with its caption in the matching bright.

Panel 3

Zap!

pink

A soft panel, with its caption in the matching bright.

Panel 4

blue

A soft panel, with its caption in the matching bright.

Panel 5

violet

A soft panel, with its caption in the matching bright.

Panel 6

orange

A soft panel, with its caption in the matching bright.

comic-card/tones.tsx
import { CardDescription, CardTitle, ComicCard } from "@streamuilib/react";

const tones = ["paper", "lime", "pink", "blue", "violet", "orange"] as const;

export default function ComicCardTones() {
  return (
    <div className="grid w-full max-w-3xl grid-cols-1 gap-x-8 gap-y-12 pt-6 sm:grid-cols-3">
      {tones.map((tone, i) => (
        <ComicCard key={tone} tone={tone} caption={`Panel ${i + 1}`} sfx={i === 2 ? "Zap!" : undefined} tilt={i % 2 ? 1 : -1}>
          <CardTitle className="capitalize">{tone}</CardTitle>
          <CardDescription>A soft panel, with its caption in the matching bright.</CardDescription>
        </ComicCard>
      ))}
    </div>
  );
}

Installation

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

<ComicCard caption="Meanwhile…" sfx="Kapow!">
  <CardTitle>Deploys ship in seconds</CardTitle>
  <CardDescription>Every pull request gets a live URL.</CardDescription>
</ComicCard>

API

PropTypeDefaultDescription
tone"paper" | "lime" | "pink" | "blue" | "violet" | "orange""paper"Panel fill (a soft tint); the caption uses the matching bright.
captionReactNode—A narration box stuck over the top-left corner.
sfxReactNode—A starburst sound effect stuck on the top-right corner. Keep it to a word.
halftonebooleantruePrint-style halftone dots over the fill.
tiltnumber-1Rotation in degrees. 0 keeps it level.
...propsComponentProps<"div">—Native attributes; children go inside the panel.

Accessibility

  • The panel shape is decorative and hidden; the caption, sound effect, and contents are plain text in reading order.
  • Use CardTitle with the heading level that fits your page (as="h2" and so on).
  • The hover lift and burst animation stop when the user prefers reduced motion.

Source

packages/react/src/ComicCard.tsx
import type { ComponentProps, ReactNode } from "react";
import { cn } from "./cn";
import type { CardTone } from "./Card";
import { ComicBadge } from "./ComicBadge";

export type ComicCardProps = ComponentProps<"div"> & {
  /** Panel fill: a soft tint, with the caption in the matching bright. */
  tone?: CardTone;
  /** A narration box stuck over the top-left corner ("MEANWHILE..."). */
  caption?: ReactNode;
  /** A starburst sound effect stuck on the top-right corner ("KAPOW!"). */
  sfx?: ReactNode;
  /** Halftone dots over the fill, like printed comics. */
  halftone?: boolean;
  /** Tilt in degrees. */
  tilt?: number;
};

const panelFill: Record<CardTone, string> = {
  paper: "fill-paper-raised",
  lime: "fill-lime-soft",
  pink: "fill-pink-soft",
  blue: "fill-blue-soft",
  violet: "fill-violet-soft",
  orange: "fill-orange-soft",
};
const captionBg: Record<CardTone, string> = {
  paper: "bg-lime",
  lime: "bg-lime",
  pink: "bg-pink",
  blue: "bg-blue",
  violet: "bg-violet",
  orange: "bg-orange",
};
const sfxTone = { paper: "pink", lime: "pink", pink: "lime", blue: "orange", violet: "lime", orange: "blue" } as const;

/** A comic panel: a slightly skewed box. */
const PANEL = "M2 4 L98 1 L99 97 L1 99 Z";
const CLIP = "polygon(2% 4%, 98% 1%, 99% 97%, 1% 99%)";

/**
 * A comic-book panel card: a skewed panel with halftone dots, an ink outline
 * and hard shadow that follow its shape, an optional narration caption on
 * the top-left corner, and an optional starburst sound effect on the
 * top-right. Lifts on hover; the burst spins. Compose the inside freely
 * (CardTitle and CardDescription work well). Motion stops for reduced motion.
 */
export function ComicCard({
  tone = "paper",
  caption,
  sfx,
  halftone = true,
  tilt = -1,
  className,
  style,
  children,
  ...props
}: ComicCardProps) {
  return (
    <div
      className={cn("sui-comic-card relative flex flex-col text-ink", caption ? "pt-10" : "pt-6", "px-6 pb-6", className)}
      style={{ rotate: `${tilt}deg`, ...style }}
      {...props}
    >
      <svg aria-hidden viewBox="0 0 100 100" preserveAspectRatio="none" overflow="visible" className="sui-comic-card-shape absolute inset-0 -z-0 size-full">
        <path d={PANEL} className={cn("stroke-ink", panelFill[tone])} strokeWidth={3} strokeLinejoin="round" vectorEffect="non-scaling-stroke" />
      </svg>
      {/* Halftone dots, clipped to the panel, so they stay round at any size. */}
      {halftone && (
        <span
          aria-hidden
          className="sui-comic-card-dots pointer-events-none absolute inset-0"
          style={{
            clipPath: CLIP,
            backgroundImage: "radial-gradient(color-mix(in oklab, var(--ink) 14%, transparent) 1.2px, transparent 1.6px)",
            backgroundSize: "8px 8px",
          }}
        />
      )}
      {caption && (
        <p
          className={cn(
            "absolute -top-3 -left-3 z-10 max-w-[80%] -rotate-2 border-[3px] border-ink px-2.5 py-1 font-mono text-xs font-bold tracking-wide uppercase text-on-bright shadow-[3px_3px_0_var(--ink)]",
            captionBg[tone],
          )}
        >
          {caption}
        </p>
      )}
      {sfx && (
        <span className="sui-comic-card-sfx absolute -top-7 -right-6 z-10">
          <ComicBadge shape="burst" tone={sfxTone[tone]} size="sm" tilt={12}>
            {sfx}
          </ComicBadge>
        </span>
      )}
      <div className="relative flex flex-1 flex-col gap-2">{children}</div>
    </div>
  );
}