NIMA MZ

Hourglass Loader

Component

About

Progress indicator that is a real hourglass: sand drains through a grain stream into a growing heap. Given a value the level is the progress; without one the glass empties, turns over and keeps going. Canvas, no deps, role=progressbar, still under reduced motion.

Built with React and Tailwind. Copy the prompt and paste it into Claude Code, Cursor or Lovable: your agent adds Hourglass Loader to your project and fits it to your theme.

Code

How Hourglass Loader is used: the demo's code. The component's own source comes with a free account.

Usage.tsx

"use client"

import * as React from "react"
import { HourglassLoader } from "@/components/ui/hourglass-loader"

const FILES = [
  { name: "brand-film-v3.mov", size: "38.2 MB" },
  { name: "cover-1600.png", size: "2.9 MB" },
  { name: "captions.srt", size: "18 KB" },
]

const settings = {
  duration: 6,
  size: 168,
  startAt: 62,
  grain: 1,
  sandUseTheme: false,
  sandColor: "#b9883c",
  headline: "Time you can see.",
  subline: "Sand for progress when you know how long, a turn of the glass when you do not.",
}

export default function Demo(props: Partial<typeof settings>) {
  const s = { ...settings, ...props }
  const [percent, setPercent] = React.useState(s.startAt)
  const [running, setRunning] = React.useState(false)
  const [turns, setTurns] = React.useState(0)
  const timer = React.useRef(0)

  React.useEffect(() => () => window.clearInterval(timer.current), [])

  // The resting level follows the knob while nothing is uploading.
  React.useEffect(() => {
    if (!running) setPercent(s.startAt)
  }, [s.startAt])

  const start = () => {
    window.clearInterval(timer.current)
    setPercent(0)
    setRunning(true)
    const startedAt = performance.now()
    // A few updates a second are plenty: the sand flows toward each value at its own pace, and the
    // progressbar's announced value does not chatter.
    timer.current = window.setInterval(() => {
      const p = Math.min(100, ((performance.now() - startedAt) / (s.duration * 1000)) * 100)
      setPercent(p)
      if (p >= 100) {
        window.clearInterval(timer.current)
        window.setTimeout(() => setRunning(false), 800)
      }
    }, 200)
  }

  const done = FILES.filter((_, i) => percent >= ((i + 1) / FILES.length) * 100 - 0.01).length
  const sand = s.sandUseTheme ? "var(--color-primary)" : s.sandColor

  return (
    <div className="bg-background flex min-h-[max(560px,100svh)] w-full flex-col items-center justify-center px-6 py-16">
      <div className="flex w-full max-w-3xl flex-col items-center text-center">
        <p className="text-muted-foreground border-border mb-5 inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-medium">
          <span aria-hidden="true" className="bg-primary size-1.5 rounded-full" />
          Uploads
        </p>
        <h1 className="text-foreground text-4xl font-semibold tracking-tight text-balance sm:text-5xl">
          {s.headline}
        </h1>
        <p className="text-muted-foreground mt-4 max-w-md text-base text-pretty">{s.subline}</p>

        <div className="bg-card text-card-foreground border-border mt-10 flex w-full max-w-xl flex-col items-center gap-6 rounded-2xl border p-8 shadow-sm sm:flex-row sm:items-center sm:gap-8 sm:text-left">
          <HourglassLoader
            value={percent}
            running={running}
            duration={s.duration}
            size={s.size}
            grain={s.grain}
            sandColor={sand}
            label="Upload progress"
          />
          <div className="min-w-0 flex-1">
            <p className="text-sm font-medium">Uploading 3 files · 41.1 MB</p>
            <p className="text-muted-foreground mt-1 font-mono text-xs tabular-nums">
              {Math.round(percent)}% · {done} of {FILES.length} done
            </p>
            <ul className="mt-4 space-y-2 text-sm">
              {FILES.map((file, i) => {
                const finished = i < done
                return (
                  <li key={file.name} className="flex items-center justify-between gap-3">
                    <span className="flex min-w-0 items-center gap-2">
                      <span
                        aria-hidden="true"
                        className={`size-1.5 shrink-0 rounded-full ${finished ? "bg-primary" : "bg-border"}`}
                      />
                      <span className="truncate">{file.name}</span>
                    </span>
                    <span className="text-muted-foreground font-mono text-xs tabular-nums">
                      {finished ? "done" : file.size}
                    </span>
                  </li>
                )
              })}
            </ul>
            <button
              type="button"
              id="cta-primary"
              data-slot="cta-primary"
              onClick={start}
              disabled={running}
              className="bg-primary text-primary-foreground mt-5 inline-flex h-11 cursor-pointer items-center justify-center rounded-md px-4 text-sm font-medium shadow-sm transition-[transform,box-shadow] duration-150 hover:shadow-md focus-visible:ring-[3px] focus-visible:ring-ring/50 active:scale-[0.98] disabled:cursor-progress disabled:opacity-80"
            >
              {running ? "Uploading…" : percent >= 100 ? "Upload again" : "Start upload"}
            </button>
          </div>
        </div>

        {/* The other states, side by side: waiting for the server, mid-encode, finished. */}
        <div className="mt-10 grid w-full max-w-xl grid-cols-3 gap-4">
          <figure className="flex flex-col items-center gap-2">
            <HourglassLoader
              size={64}
              duration={s.duration}
              grain={s.grain}
              sandColor={sand}
              label="Waiting for the server"
              onTurn={setTurns}
            />
            <figcaption className="text-muted-foreground text-xs">
              Waiting for the server{turns > 0 ? ` · turned ${turns}×` : ""}
            </figcaption>
          </figure>
          <figure className="flex flex-col items-center gap-2">
            <HourglassLoader
              size={64}
              value={24}
              running={false}
              grain={s.grain}
              sandColor={sand}
              label="Encoding"
            />
            <figcaption className="text-muted-foreground text-xs">Encoding · 24%</figcaption>
          </figure>
          <figure className="flex flex-col items-center gap-2">
            <HourglassLoader
              size={64}
              value={100}
              grain={s.grain}
              sandColor={sand}
              label="Published"
            />
            <figcaption className="text-muted-foreground text-xs">Published</figcaption>
          </figure>
        </div>
      </div>
    </div>
  )
}

More by NIMA MZ

View all
  • Agent Trace

    Agent Trace

    692
  • Sonar Grid

    Sonar Grid

    586
  • Receipt Pricing

    Receipt Pricing

    148
  • Import Patchbay

    Import Patchbay

    124
  • Lookout

    Lookout

    85
  • Moiré Field

    Moiré Field

    31
  • Meridian Rail

    Meridian Rail

    2
  • Split Flap Button

    Split Flap Button

    1