stream/ui
Browse the library

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%
off
HotBetaHey 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/react
Open in v0 (opens in a new tab)
usage.tsx
import { ComicBadge } from "@streamuilib/react";

<ComicBadge shape="burst">New!</ComicBadge>

API

PropTypeDefaultDescription
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.
tiltnumber-6Rotation 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.
...propsComponentProps<"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>
  );
}