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/reactusage.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
| Prop | Type | Default | Description |
|---|---|---|---|
| tone | "paper" | "lime" | "pink" | "blue" | "violet" | "orange" | "paper" | Panel fill (a soft tint); the caption uses the matching bright. |
| caption | ReactNode | — | A narration box stuck over the top-left corner. |
| sfx | ReactNode | — | A starburst sound effect stuck on the top-right corner. Keep it to a word. |
| halftone | boolean | true | Print-style halftone dots over the fill. |
| tilt | number | -1 | Rotation in degrees. 0 keeps it level. |
| ...props | ComponentProps<"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>
);
}