Comic Button
A comic-book button that lifts, slams into its shadow, and pops a POW! when you click it.
betaComicButton.tsx
Examples
Shapes
A skewed panel (the default, for any label), a speech bubble, a ribbon, and round burst, splat, and blob shapes for one-word calls.
comic-button/shapes.tsx
"use client";
import { ComicButton } from "@streamuilib/react";
export default function ComicButtonShapes() {
return (
<div className="flex flex-wrap items-center justify-center gap-8">
<ComicButton>Get started</ComicButton>
<ComicButton shape="bubble" tone="paper">
Say hi!
</ComicButton>
<ComicButton shape="ribbon" tone="violet" tilt={-3}>
Join the club
</ComicButton>
<ComicButton shape="burst" tone="pink" tilt={6}>
Buy!
</ComicButton>
<ComicButton shape="splat" tone="orange" tilt={-8}>
Go
</ComicButton>
<ComicButton shape="blob" tone="blue" tilt={3}>
Play
</ComicButton>
</div>
);
}Sizes, icons, and options
Set your own pop word, turn the pop or the halftone dots off, or disable it.
comic-button/sizes.tsx
"use client";
import { ArrowRight, Heart } from "lucide-react";
import { ComicButton } from "@streamuilib/react";
export default function ComicButtonSizes() {
return (
<div className="flex flex-col items-center gap-8">
<div className="flex flex-wrap items-center justify-center gap-6">
<ComicButton size="sm">Small</ComicButton>
<ComicButton size="md">Medium</ComicButton>
<ComicButton size="lg">
Large <ArrowRight aria-hidden />
</ComicButton>
</div>
<div className="flex flex-wrap items-center justify-center gap-6">
<ComicButton tone="pink" pop="LOVE IT!">
<Heart aria-hidden /> Like
</ComicButton>
<ComicButton tone="paper" halftone={false} pop={false} tilt={0}>
Quiet, no pop
</ComicButton>
<ComicButton tone="blue" disabled>
Disabled
</ComicButton>
</div>
</div>
);
}Installation
bun add @streamuilib/reactusage.tsx
import { ComicButton } from "@streamuilib/react";
<ComicButton onClick={save}>Get started</ComicButton>API
| Prop | Type | Default | Description |
|---|---|---|---|
| shape | "panel" | "bubble" | "ribbon" | "burst" | "seal" | "splat" | "blob" | "panel" | The outline. panel, bubble, and ribbon grow with their label; the round ones are fixed-size for a word or two. |
| tone | "lime" | "pink" | "blue" | "violet" | "orange" | "paper" | "lime" | Fill color. |
| size | "sm" | "md" | "lg" | "md" | Button and label size. |
| tilt | number | -2 | Rotation in degrees. 0 keeps it level. |
| pop | string | string[] | false | ["POW!", "BAM!", "ZAP!", "WHAM!", "BOOM!"] | The sound effect that pops out on click; a list cycles. false turns it off. |
| halftone | boolean | true | Print-style halftone dots over the fill. |
| ...props | ComponentProps<"button"> | — | Native button attributes, including onClick, type, and disabled. |
Accessibility
- It's a real <button> (type="button" by default) with a visible focus ring, so Tab, Enter, and Space work.
- The shape and the POW! pop are decorative and hidden from screen readers; the label is the accessible name.
- The lift, press, and pop stop when the user prefers reduced motion.
Source
packages/react/src/ComicButton.tsx
"use client";
import { useId, useRef, useState, type ComponentProps, type MouseEvent } from "react";
import { cn } from "./cn";
import type { BadgeTone } from "./Badge";
import { SHAPES, type ComicBadgeShape } from "./ComicBadge";
export type ComicButtonShape = "panel" | ComicBadgeShape;
export type ComicButtonSize = "sm" | "md" | "lg";
export type ComicButtonProps = ComponentProps<"button"> & {
shape?: ComicButtonShape;
tone?: BadgeTone;
size?: ComicButtonSize;
/** Tilt in degrees. */
tilt?: number;
/**
* The sound effect that pops out on click. A string, a list to cycle
* through, or false for none.
*/
pop?: string | string[] | false;
/** Halftone dots over the fill, like printed comics. */
halftone?: boolean;
};
/** A comic panel: a slightly skewed box. Fits any label. */
const PANEL = { d: "M3 5 L97 1 L99 95 L1 99 Z", viewBox: "0 0 100 100", round: false };
const fills: Record<BadgeTone, string> = {
lime: "fill-lime text-on-bright",
pink: "fill-pink text-on-bright",
blue: "fill-blue text-on-bright",
violet: "fill-violet text-on-bright",
orange: "fill-orange text-on-bright",
paper: "fill-paper-raised text-ink",
};
const roundSize: Record<ComicButtonSize, string> = { sm: "size-18 text-xs", md: "size-24 text-sm", lg: "size-30 text-base" };
const wideSize: Record<ComicButtonSize, string> = { sm: "h-10 px-4 text-xs", md: "h-13 px-6 text-sm", lg: "h-16 px-8 text-base" };
/** Speech bubbles need room for the tail underneath. */
const bubbleSize: Record<ComicButtonSize, string> = { sm: "h-12 px-4 pb-3 text-xs", md: "h-16 px-6 pb-4 text-sm", lg: "h-20 px-8 pb-5 text-base" };
const DEFAULT_POPS = ["POW!", "BAM!", "ZAP!", "WHAM!", "BOOM!"];
/**
* A comic-book button: a panel, burst, seal, splat, blob, speech bubble, or
* ribbon with an ink outline and a hard shadow that follow the shape. It
* lifts on hover, slams into its shadow on press, and pops a sound effect
* ("POW!") on click. Motion stops for reduced motion.
*/
export function ComicButton({
shape = "panel",
tone = "lime",
size = "md",
tilt = -2,
pop = DEFAULT_POPS,
halftone = true,
type = "button",
className,
style,
children,
onClick,
...props
}: ComicButtonProps) {
const s = shape === "panel" ? PANEL : SHAPES[shape];
const dots = useId().replace(/:/g, "");
const [pops, setPops] = useState<{ id: number; word: string }[]>([]);
const n = useRef(0);
function handleClick(e: MouseEvent<HTMLButtonElement>) {
onClick?.(e);
if (pop === false || e.defaultPrevented) return;
const words = Array.isArray(pop) ? pop : [pop];
const id = ++n.current;
setPops((p) => [...p.slice(-2), { id, word: words[(id - 1) % words.length] }]);
setTimeout(() => setPops((p) => p.filter((x) => x.id !== id)), 650);
}
return (
<button
type={type}
onClick={handleClick}
className={cn(
"sui-comic-btn group relative inline-grid shrink-0 cursor-pointer place-items-center font-mono font-bold tracking-tight uppercase select-none",
"disabled:pointer-events-none disabled:opacity-50",
"focus-visible:outline-3 focus-visible:outline-offset-4 focus-visible:outline-blue",
s.round ? roundSize[size] : shape === "bubble" ? bubbleSize[size] : wideSize[size],
fills[tone],
className,
)}
style={{ rotate: `${tilt}deg`, ...style }}
{...props}
>
<svg aria-hidden viewBox={s.viewBox} preserveAspectRatio="none" overflow="visible" className="sui-comic-btn-shape absolute inset-0 size-full">
{halftone && (
<defs>
<pattern id={dots} width="6" height="6" patternUnits="userSpaceOnUse">
<circle cx="1.5" cy="1.5" r="1" fill="var(--ink)" opacity="0.14" />
</pattern>
</defs>
)}
<path d={s.d} className="stroke-ink" strokeWidth={3} strokeLinejoin="round" vectorEffect="non-scaling-stroke" />
{halftone && <path d={s.d} fill={`url(#${dots})`} />}
</svg>
<span className="relative inline-flex items-center gap-2 whitespace-nowrap [&_svg]:size-4.5 [&_svg]:shrink-0">{children}</span>
{pops.map((p) => (
<span
key={p.id}
aria-hidden
className="sui-comic-pop pointer-events-none absolute -top-3 -right-4 z-10 border-[3px] border-ink bg-paper-raised px-1.5 py-0.5 font-mono text-xs font-bold whitespace-nowrap text-ink shadow-[2px_2px_0_var(--ink)]"
>
{p.word}
</span>
))}
</button>
);
}