Comic Badge
A comic-book sticker for short labels: a burst, seal, splat, blob, speech bubble, or ribbon.
betaComicBadge.tsx
Examples
Shapes
Six outlines, each with an ink stroke and a hard shadow that follow the shape.
New!50%
offHotBetaHey there!Best seller
offHotBetaHey there!Best seller
comic-badge/shapes.tsx
import { ComicBadge } from "@streamuilib/react";
export default function ComicBadgeShapes() {
return (
<div className="flex flex-wrap items-center justify-center gap-8">
<ComicBadge shape="burst">New!</ComicBadge>
<ComicBadge shape="seal" tone="pink" tilt={8}>
50%
<br />
off
</ComicBadge>
<ComicBadge shape="splat" tone="orange" tilt={-10}>
Hot
</ComicBadge>
<ComicBadge shape="blob" tone="blue" tilt={4}>
Beta
</ComicBadge>
<ComicBadge shape="bubble" tone="paper">
Hey there!
</ComicBadge>
<ComicBadge shape="ribbon" tone="violet" tilt={-3}>
Best seller
</ComicBadge>
</div>
);
}Tones and sizes
limepinkbluevioletorangepaper
SmMdLgLimited
comic-badge/tones-sizes.tsx
import { ComicBadge } from "@streamuilib/react";
const tones = ["lime", "pink", "blue", "violet", "orange", "paper"] as const;
export default function ComicBadgeTonesSizes() {
return (
<div className="flex flex-col items-center gap-8">
<div className="flex flex-wrap items-center justify-center gap-5">
{tones.map((tone, i) => (
<ComicBadge key={tone} shape="burst" tone={tone} size="sm" tilt={i % 2 ? 6 : -6}>
{tone}
</ComicBadge>
))}
</div>
<div className="flex flex-wrap items-end justify-center gap-6">
<ComicBadge shape="seal" size="sm">Sm</ComicBadge>
<ComicBadge shape="seal" size="md">Md</ComicBadge>
<ComicBadge shape="seal" size="lg">Lg</ComicBadge>
<ComicBadge shape="ribbon" tone="pink" size="lg" tilt={0}>
Limited
</ComicBadge>
</div>
</div>
);
}Animated
spin turns only the shape, so the text stays level; wiggle and pulse move the whole badge.
Sale!WowTry me
comic-badge/animated.tsx
import { ComicBadge } from "@streamuilib/react";
export default function ComicBadgeAnimated() {
return (
<div className="flex flex-wrap items-center justify-center gap-10">
<ComicBadge shape="burst" animate="spin" size="lg">
Sale!
</ComicBadge>
<ComicBadge shape="splat" tone="pink" animate="wiggle">
Wow
</ComicBadge>
<ComicBadge shape="bubble" tone="lime" animate="pulse" tilt={-2}>
Try me
</ComicBadge>
</div>
);
}Installation
bun add @streamuilib/reactusage.tsx
import { ComicBadge } from "@streamuilib/react";
<ComicBadge shape="burst">New!</ComicBadge>API
| Prop | Type | Default | Description |
|---|---|---|---|
| shape | "burst" | "seal" | "splat" | "blob" | "bubble" | "ribbon" | "burst" | The outline. burst, seal, splat, and blob are round and fixed-size; bubble and ribbon grow with their text. |
| tone | "lime" | "pink" | "blue" | "violet" | "orange" | "paper" | "lime" | Fill color, the same tones as Badge. |
| size | "sm" | "md" | "lg" | "md" | Badge and text size. |
| tilt | number | -6 | Rotation in degrees, for a stuck-on-by-hand look. 0 keeps it level. |
| animate | "none" | "spin" | "wiggle" | "pulse" | "none" | A looping animation. Stops for reduced motion. |
| ...props | ComponentProps<"span"> | — | Native attributes. |
Accessibility
- It's plain inline text, so screen readers read the label; the shape is decorative and hidden.
- Keep labels short: round shapes fit a word or two (use <br /> for a second line).
- Looping animations stop when the user prefers reduced motion.
Source
packages/react/src/ComicBadge.tsx
import type { ComponentProps } from "react";
import { cn } from "./cn";
import type { BadgeTone } from "./Badge";
export type ComicBadgeShape = "burst" | "seal" | "splat" | "blob" | "bubble" | "ribbon";
export type ComicBadgeSize = "sm" | "md" | "lg";
export type ComicBadgeAnimation = "none" | "spin" | "wiggle" | "pulse";
export type ComicBadgeProps = ComponentProps<"span"> & {
shape?: ComicBadgeShape;
tone?: BadgeTone;
size?: ComicBadgeSize;
/** Tilt in degrees, for that stuck-on-by-hand look. */
tilt?: number;
/** spin turns only the shape (the text stays level); wiggle and pulse move the whole badge. */
animate?: ComicBadgeAnimation;
};
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",
};
/** A closed polygon through points at angle i·(2π/n), radius r(i), centered in a 100×100 box. */
function ring(n: number, r: (i: number) => number, turn = -Math.PI / 2) {
const pts = Array.from({ length: n }, (_, i) => {
const a = turn + (i * 2 * Math.PI) / n;
return `${(50 + r(i) * Math.cos(a)).toFixed(2)},${(50 + r(i) * Math.sin(a)).toFixed(2)}`;
});
return `M${pts.join("L")}Z`;
}
const SPLAT = [50, 41, 48, 39, 50, 43, 46, 38, 50, 42, 47, 40];
/** Each shape: its path, its viewBox, and whether it's round (fixed square) or wide (sized by its text). */
const SHAPES: Record<ComicBadgeShape, { d: string; viewBox: string; round: boolean }> = {
// The pointed circle: 14 spikes.
burst: { d: ring(28, (i) => (i % 2 ? 37 : 50)), viewBox: "0 0 100 100", round: true },
// A scalloped seal: a wavy edge all the way round.
seal: { d: ring(144, (i) => 45 + 4 * Math.cos((i * 18 * 2 * Math.PI) / 144)), viewBox: "0 0 100 100", round: true },
// An uneven splat: spikes of different lengths.
splat: { d: ring(24, (i) => (i % 2 ? 34 + (i % 4) : SPLAT[(i / 2) % SPLAT.length])), viewBox: "0 0 100 100", round: true },
// An organic blob.
blob: { d: "M50 3C77 2 97 20 96 47C95 74 76 97 49 96C22 95 3 77 4 50C5 23 23 4 50 3Z", viewBox: "0 0 100 100", round: true },
// A speech bubble with a tail off the bottom left.
bubble: { d: "M12 2H88Q98 2 98 12V44Q98 54 88 54H36L18 68L22 54H12Q2 54 2 44V12Q2 2 12 2Z", viewBox: "0 0 100 70", round: false },
// A banner with notched ends.
ribbon: { d: "M0 2H100L90 21L100 40H0L10 21Z", viewBox: "0 0 100 42", round: false },
};
const roundSize: Record<ComicBadgeSize, string> = {
sm: "size-16 p-3 text-[10px]",
md: "size-22 p-4 text-xs",
lg: "size-30 p-6 text-sm",
};
const wideSize: Record<ComicBadgeShape, Record<ComicBadgeSize, string>> = {
bubble: { sm: "px-3 pt-1.5 pb-4 text-[10px]", md: "px-4 pt-2 pb-5 text-xs", lg: "px-5 pt-3 pb-7 text-sm" },
ribbon: { sm: "px-5 py-1.5 text-[10px]", md: "px-7 py-2 text-xs", lg: "px-9 py-3 text-sm" },
burst: roundSize, seal: roundSize, splat: roundSize, blob: roundSize,
};
/**
* A comic-book sticker: short text on a burst, seal, splat, blob, speech
* bubble, or ribbon, with an ink outline and a hard shadow that follow the
* shape. Keep the text short ("NEW!", "50% OFF", "HOT"). Animations stop
* for reduced motion.
*/
export function ComicBadge({
shape = "burst",
tone = "lime",
size = "md",
tilt = -6,
animate = "none",
className,
style,
children,
...props
}: ComicBadgeProps) {
const s = SHAPES[shape];
return (
<span
data-sui-comic={animate === "none" ? undefined : animate}
className={cn(
"sui-comic relative inline-grid shrink-0 place-items-center",
s.round ? roundSize[size] : wideSize[shape][size],
fills[tone],
className,
)}
style={{ rotate: `${tilt}deg`, ...style }}
{...props}
>
<svg
aria-hidden
viewBox={s.viewBox}
preserveAspectRatio="none"
overflow="visible"
className="sui-comic-shape absolute inset-0 size-full drop-shadow-[3px_3px_0_var(--ink)]"
>
<path d={s.d} className="stroke-ink" strokeWidth={3} strokeLinejoin="round" vectorEffect="non-scaling-stroke" />
</svg>
<span className="relative text-center font-mono leading-[1.05] font-bold tracking-tight whitespace-nowrap uppercase">{children}</span>
</span>
);
}