stream/ui
Browse the library

Comic Progress

A hand-inked progress bar with speed lines, the percentage on a starburst, and a Done! sticker.

betaComicProgress.tsx

Examples

Tones

Speed lines race along the fill; at 100% a sticker slaps on.

comic-progress/tones.tsx
import { ComicProgress } from "@streamuilib/react";

export default function ComicProgressTones() {
  return (
    <div className="flex w-full max-w-md flex-col gap-7 pt-3">
      <ComicProgress value={30} aria-label="Upload" />
      <ComicProgress value={64} tone="lime" aria-label="Level" />
      <ComicProgress value={85} tone="blue" aria-label="Storage" />
      <ComicProgress value={100} tone="orange" aria-label="Quest" />
    </div>
  );
}

Steps

Use max for anything that isn't a percentage.

Step 1 of 5
comic-progress/interactive.tsx
"use client";

import { useState } from "react";
import { ComicButton, ComicProgress } from "@streamuilib/react";

export default function ComicProgressInteractive() {
  const [step, setStep] = useState(1);
  return (
    <div className="flex w-full max-w-md flex-col gap-6 pt-3">
      <ComicProgress value={step} max={5} tone="violet" aria-label="Onboarding" doneLabel="Hooray!" />
      <div className="flex items-center gap-3">
        <ComicButton size="sm" tone="paper" pop={false} onClick={() => setStep((s) => Math.max(0, s - 1))}>
          Back
        </ComicButton>
        <ComicButton size="sm" pop={step >= 4 ? "YES!" : "NEXT!"} onClick={() => setStep((s) => Math.min(5, s + 1))}>
          Next
        </ComicButton>
        <span className="font-mono text-sm">Step {step} of 5</span>
      </div>
    </div>
  );
}

Installation

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

<ComicProgress value={60} aria-label="Upload" />

API

PropTypeDefaultDescription
valuenumber—Current value. Clamped between 0 and max.
maxnumber100Value that means complete.
tone"lime" | "pink" | "blue" | "violet" | "orange""pink"Fill color.
showValuebooleantrueThe percentage on a starburst at the front of the fill.
doneLabelReactNode | false"Done!"The sticker at 100%; false for none.
...propsComponentProps<"div">—Native attributes; pass aria-label or aria-labelledby.

Accessibility

  • Uses role="progressbar" with aria-valuenow, aria-valuemin, and aria-valuemax, like Progress.
  • Give it a name with aria-label or aria-labelledby.
  • The starburst and sticker are decorative; the speed lines stop for reduced motion.

Source

packages/react/src/ComicProgress.tsx
import type { ReactNode } from "react";
import { cn } from "./cn";
import { halftone } from "./comic";
import { SHAPES } from "./ComicBadge";
import type { ProgressProps } from "./Progress";
import { toneBg } from "./tones";

export type ComicProgressProps = ProgressProps & {
  /** The percentage on a starburst riding the end of the fill. */
  showValue?: boolean;
  /** The sticker slapped on when complete. Defaults to "Done!"; false for none. */
  doneLabel?: ReactNode | false;
};

/**
 * A comic-book progress bar: a hand-inked track with halftone dots, a fill
 * with speed lines racing along it, the percentage on a starburst riding the
 * front, and a "Done!" sticker at 100%. Motion stops for reduced motion.
 * Give it a name with `aria-label` or `aria-labelledby`.
 */
export function ComicProgress({
  value,
  max = 100,
  tone = "pink",
  showValue = true,
  doneLabel = "Done!",
  className,
  ...props
}: ComicProgressProps) {
  const clamped = Math.min(max, Math.max(0, value));
  const percent = max > 0 ? (clamped / max) * 100 : 0;
  const done = percent >= 100;

  return (
    <div
      role="progressbar"
      aria-valuenow={clamped}
      aria-valuemin={0}
      aria-valuemax={max}
      data-done={done || undefined}
      className={cn("sui-comic-progress relative h-7", showValue && "mr-5", className)}
      {...props}
    >
      <div
        className="absolute inset-0 overflow-hidden rounded-[12px_5px_10px_6px/6px_10px_5px_12px] border-[3px] border-ink bg-paper-sunken shadow-[4px_4px_0_var(--ink)]"
        style={halftone(14)}
      >
        <div
          className={cn(
            "sui-comic-progress-fill h-full transition-[width] duration-300 ease-out motion-reduce:transition-none",
            percent > 0 && !done && "border-r-[3px] border-ink",
            toneBg[tone],
          )}
          style={{ width: `${percent}%` }}
        />
      </div>
      {showValue && (
        <span
          aria-hidden
          className="sui-comic-progress-burst absolute top-1/2 grid size-12 -translate-1/2 place-items-center font-mono text-[10px] font-bold text-ink transition-[left] duration-300 ease-out motion-reduce:transition-none"
          style={{ left: `${percent}%` }}
        >
          <svg viewBox="0 0 100 100" overflow="visible" className="absolute inset-0 size-full drop-shadow-[2px_2px_0_var(--ink)]">
            <path d={SHAPES.burst.d} className="fill-paper-raised stroke-ink" strokeWidth={6} strokeLinejoin="round" />
          </svg>
          <span className="relative">{Math.round(percent)}%</span>
        </span>
      )}
      {done && doneLabel && (
        <span
          aria-hidden
          className="sui-comic-stamp absolute -top-5 left-3 z-10 -rotate-6 border-[3px] border-ink bg-lime px-1.5 py-0.5 font-mono text-[10px] font-bold tracking-wide uppercase text-on-bright shadow-[2px_2px_0_var(--ink)]"
        >
          {doneLabel}
        </span>
      )}
    </div>
  );
}