Skip to content
Ballpoint
Contents

Progress

A pill shaded in as the work gets done; indeterminate slides a patch of shading along.

Built on Base UI Progress
Uploading
x
Hatched
x
Done
x
Working on it
x

Installation

pnpm dlx shadcn@latest add @ballpoint/progress
Or copy the source
ui/progress.tsx
"use client";

import { createContext, useContext, useId, useMemo, type CSSProperties } from "react";
import { Progress as ProgressPrimitive } from "@base-ui/react/progress";
import { cn } from "@/lib/utils";
import { penStyle, useInkFrame, useInkSeed, usePen, type InkFill, type Pen } from "@/hooks/use-ink-box";
import { InkSvg, Stroke } from "@/lib/ink";
import { hatchStrokes, penBoxStrokes, roundedRectPath, scribbleFill, shadeFill } from "@/lib/ink-sketch";

type TrackInk = { w: number; h: number; r: number; s: number; pen: Pen };
const TrackContext = createContext<TrackInk | null>(null);

function Progress({ className, children, value, seed, roughness, passes, radius, fill, draw, weight, speed, ...props }: ProgressPrimitive.Root.Props & TrackPen) {
  return (
    <ProgressPrimitive.Root value={value} data-slot="progress" className={cn("group/progress flex flex-wrap gap-x-3 gap-y-2", className)} {...props}>
      {children}
      <ProgressTrack seed={seed} roughness={roughness} passes={passes} radius={radius} fill={fill} draw={draw} weight={weight} speed={speed}>
        <ProgressIndicator />
      </ProgressTrack>
    </ProgressPrimitive.Root>
  );
}

type TrackPen = Pick<Pen, "roughness" | "passes" | "radius" | "fill" | "draw" | "weight" | "speed"> & { seed?: string | number };

/** A pill pulled once along the full length; the indicator inside shades it in. */
function ProgressTrack({ className, children, seed, roughness, passes, radius, fill, draw, weight, speed, ...props }: ProgressPrimitive.Track.Props & TrackPen) {
  const pen = usePen({ roughness, passes, radius, fill, draw, weight, speed });
  const s = useInkSeed(seed);
  const { ref, w, h, frame } = useInkFrame([320, 12], { pad: 5 });
  const r = pen.radius === undefined || pen.radius === "full" ? h / 2 : Math.min(pen.radius, h / 2);
  const outline = useMemo(
    () => penBoxStrokes(s, w, h, { roughness: pen.roughness ?? 1, passes: pen.passes ?? 1, corners: "joined", radius: r }),
    [s, w, h, pen.roughness, pen.passes, r],
  );
  const mode = pen.draw ?? "auto";
  const track = useMemo(() => ({ w, h, r, s, pen }), [w, h, r, s, pen]);
  return (
    <ProgressPrimitive.Track data-slot="progress-track" className={cn("relative flex h-3 w-full items-center", className)} {...props}>
      <InkSvg ref={ref} {...frame} pending={mode === "auto"} className="text-ink-line" style={{ ...frame.style, ...penStyle(pen) }}>
        {outline.map((d, i) => (
          <Stroke key={i} d={d} draw={mode} delay={i * 240} duration={520} width={[1.3, 1, 0.9][i]} opacity={[1, 0.7, 0.5][i]} />
        ))}
      </InkSvg>
      {/* The indicator moves inside a clip shaped like the track, so the
          shading (and the indeterminate patch sliding in and out) never
          spills past it. The track itself can't clip: its outline overshoots. */}
      <div data-slot="progress-clip" className="absolute inset-0 overflow-hidden" style={{ borderRadius: r }}>
        <TrackContext value={track}>{children}</TrackContext>
      </div>
    </ProgressPrimitive.Track>
  );
}

/**
 * The done part, shaded in. The shading is drawn once for the whole track
 * and the indicator only uncovers it, so the strokes hold still as the value
 * grows. Indeterminate progress slides a patch of shading along.
 */
function ProgressIndicator({ className, style, ...props }: ProgressPrimitive.Indicator.Props) {
  const track = useContext(TrackContext);
  // Rounded like the track, so the shading ends in a pill, not a cut.
  const round = track ? { borderRadius: track.r } : undefined;
  return (
    <ProgressPrimitive.Indicator
      data-slot="progress-indicator"
      style={typeof style === "function" ? (state) => ({ ...round, ...style(state) }) : { ...round, ...style }}
      className={cn(
        // .ink-progress (base.css) slides it along when indeterminate.
        "ink-progress absolute inset-y-0 left-0 overflow-hidden transition-[width] duration-(--dur-state) ease-out motion-reduce:transition-none",
        className,
      )}
      {...props}
    >
      {track && <Shading {...track} />}
    </ProgressPrimitive.Indicator>
  );
}

function Shading({ w, h, r, s, pen }: TrackInk) {
  const id = useId().replace(/[^\w-]/g, "");
  const fill: InkFill = pen.fill ?? "shade";
  const d = useMemo(() => {
    if (fill === "flat") return "";
    if (fill === "hatch") return hatchStrokes(s + 30, w, h, { gap: 2.4, angle: -50, jitter: 0.3 }).join("");
    if (fill === "scribble") return scribbleFill(s + 30, w, h, { gap: 2.2 });
    return shadeFill(s + 30, w, h, { gap: 1.8, angle: -10, overrun: 2 });
  }, [fill, s, w, h]);
  return (
    <InkSvg box={[0, 0, w, h]} className="top-0 left-0 text-ink" style={{ width: w, height: h, ...penStyle(pen) } as CSSProperties}>
      <defs>
        <clipPath id={`${id}-clip`}>
          <path d={roundedRectPath(w, h, r)} />
        </clipPath>
      </defs>
      <g clipPath={`url(#${id}-clip)`}>{d ? <Stroke d={d} width={1.2} /> : <path d={roundedRectPath(w, h, r)} className="ink-fill" style={{ opacity: 1 }} />}</g>
    </InkSvg>
  );
}

function ProgressLabel({ className, ...props }: ProgressPrimitive.Label.Props) {
  return <ProgressPrimitive.Label className={cn("text-base", className)} data-slot="progress-label" {...props} />;
}

function ProgressValue({ className, ...props }: ProgressPrimitive.Value.Props) {
  return <ProgressPrimitive.Value className={cn("ml-auto text-sm text-ink-3 tabular-nums", className)} data-slot="progress-value" {...props} />;
}

export { Progress, ProgressTrack, ProgressIndicator, ProgressLabel, ProgressValue };

Usage

tsx
import { Progress, ProgressLabel, ProgressValue } from "@/components/ui/progress"

<Progress value={40}>
  <ProgressLabel>Uploading</ProgressLabel>
  <ProgressValue />
</Progress>

API reference

valuenumber | null
null for indeterminate: a patch of shading slides along.
seedstring | number
Pins the drawing.

Every other prop goes to Base UI's Progress.

Pen settings

The drawing also takes draw, roughness, passes, radius, fill, weight and speed, as props or from the nearest InkProvider. What each one does.