ephemera

Progress

A punch card that fills hole by hole.

35% punched

Install

$ npx shadcn@latest add https://ephemera.kerrshift.com/r/progress.json

Or copy the source below into components/ui/progress.tsx. Core components need the Paper Theme and Radix where noted.

Usage

example.tsx
import { Progress } from "@/components/ui/progress";

<Progress value={60} />
<Progress value={3} max={10} holes={10} />

Built on the Radix Progress, so keyboard navigation and screen reader support come for free.

Props

PropTypeDefaultDescription
value / maxnumber0 / 100How much is done.
holesnumber20Number of holes on the punch card.

Any other props are passed to the root element.

Fonts

Components read optional CSS variables and fall back to system fonts: --font-display, --font-receipt, --font-type, --font-hand and --font-stamp. This site uses Fraunces, IBM Plex Mono, Courier Prime, Caveat and Anton.

Source

components/ui/progress.tsx
"use client";

import * as React from "react";
import { Progress as ProgressPrimitive } from "radix-ui";

export interface ProgressProps extends React.ComponentProps<typeof ProgressPrimitive.Root> {
  /** Number of holes on the card. */
  holes?: number;
}

/** A punch card: each hole punched is a step closer to done. */
export function Progress({ value = 0, max = 100, holes = 20, className = "", ...props }: ProgressProps) {
  const punched = Math.round(((value ?? 0) / max) * holes);
  return (
    <ProgressPrimitive.Root
      value={value}
      max={max}
      className={`paper relative flex w-full items-center gap-[3px] rounded-[3px] border border-(--edge) bg-(--paper-raised) px-2.5 py-2 ${className}`}
      style={{ clipPath: "polygon(8px 0, 100% 0, 100% 100%, 0 100%, 0 8px)" }}
      {...props}
    >
      {Array.from({ length: holes }, (_, i) => (
        <span
          key={i}
          className="aspect-square min-w-0 flex-1 rounded-full transition-[background-color,box-shadow] duration-200"
          style={
            i < punched
              ? { background: "var(--ink)", boxShadow: "inset 0 1px 2px rgb(0 0 0 / .6)", transitionDelay: `${i * 25}ms` }
              : { boxShadow: "inset 0 0 0 1px var(--edge)" }
          }
        />
      ))}
      <ProgressPrimitive.Indicator className="sr-only" />
    </ProgressPrimitive.Root>
  );
}