Loaders
HourSand
A turned-wood sand glass. A dimple sinks in the top bulb, a thin stream with loose grains falls, a cone piles up, and when it runs out the whole frame flips over.
Download
- React
- Tailwind CSS v4
- motion
components/ui/hour-sand.tsx
"use client";
import { useEffect, useId, useState } from "react";
import { useReducedMotion } from "motion/react";
type HourSandProps = {
/** Colour of the sand. */
sand?: string;
/** Colour of the wooden frame. */
wood?: string;
/** Seconds for one full turn, before speed. */
seconds?: number;
/** 1 is the default pace; 2 is twice as fast. */
speed?: number;
/** Read out by screen readers. */
label?: string;
className?: string;
};
const clamp = (v: number) => Math.min(1, Math.max(0, v));
const span = (p: number, a: number, b: number) => clamp((p - a) / (b - a));
const ease = (t: number) => t * t * (3 - 2 * t);
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
// Overshoots a little past the end, the weight of a turned glass.
const swing = (t: number) => 1 + 2.2 * (t - 1) ** 3 + 1.2 * (t - 1) ** 2;
const DRAIN = 0.82; // The clock runs out here, then the frame turns over.
const FLIP = 0.86;
const REST = 0.38;
const GLASS = "M44 24 C44 70 74 84 76.5 100 C74 116 44 130 44 176 L116 176 C116 130 86 116 83.5 100 C86 84 116 70 116 24 Z";
const GRAINS = [0, 1, 2, 3, 4, 5];
// A turned spindle, the same from either end so the flip lands clean.
const POST = (x: number) =>
`M${x - 4} 20 q3 13 0 26 q-7 27 0 54 q-7 27 0 54 q3 13 0 26 h8 q-3 -13 0 -26 q7 -27 0 -54 q7 -27 0 -54 q-3 -13 0 -26 Z`;
/* HourSand: a turned-wood sand glass. A dimple sinks in the top bulb, a thin stream with loose grains falls, a cone piles up; when it runs out the whole frame flips over and starts again. */
export function HourSand({ sand = "#e0a955", wood = "#7a4a26", seconds = 6, speed = 1, label = "Loading", className = "" }: HourSandProps) {
const reduce = useReducedMotion();
const id = useId().replace(/:/g, "");
const [clock, setClock] = useState(REST);
useEffect(() => {
if (reduce) return;
const length = seconds / speed;
const start = performance.now() - REST * length * 1000;
let raf = 0;
const tick = (now: number) => {
setClock(((now - start) / (length * 1000)) % 1);
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [reduce, seconds, speed]);
const p = reduce ? REST : clock;
const left = 1 - span(p, 0, DRAIN); // Share of the sand still in the top bulb.
const fell = 1 - left;
const turn = span(p, FLIP, 1);
const running = p < DRAIN;
// After the flip the pile falls to the neck side, which is the shape the top bulb starts from.
const settle = ease(span(turn, 0.45, 1));
const topY = 98 - 52 * Math.sqrt(left);
const dip = left > 0 ? Math.min(10, left * 34) * Math.min(1, fell * 8) : 0;
const peak = lerp(178 - 66 * Math.sqrt(fell), 154, settle);
const edge = lerp(178 - 46 * fell ** 1.4, 154, settle);
const floor = lerp(178, 98, settle);
const fall = peak - 102;
return (
<div role="status" aria-label={label} className={`w-48 ${className}`}>
<svg viewBox="-30 -10 220 220" className="block w-full overflow-visible" aria-hidden>
<defs>
<linearGradient id={`${id}w`} x1="0" x2="1">
<stop offset="0" stopColor="#000" stopOpacity="0.45" />
<stop offset="0.35" stopColor="#fff" stopOpacity="0.24" />
<stop offset="1" stopColor="#000" stopOpacity="0.5" />
</linearGradient>
<linearGradient id={`${id}s`} x1="0" x2="1">
<stop offset="0" stopColor="#000" stopOpacity="0.26" />
<stop offset="0.4" stopColor="#fff" stopOpacity="0.18" />
<stop offset="1" stopColor="#000" stopOpacity="0.34" />
</linearGradient>
<pattern id={`${id}g`} width="9" height="7" patternUnits="userSpaceOnUse">
<circle cx="2" cy="2" r="0.7" fill="#000" opacity="0.22" />
<circle cx="6.5" cy="5" r="0.6" fill="#fff" opacity="0.3" />
</pattern>
<clipPath id={`${id}c`}>
<path d={GLASS} />
</clipPath>
</defs>
<g transform={`rotate(${180 * (running ? 0 : swing(turn))} 80 100)`}>
{[26, 134].map((x) => (
<g key={x}>
<path
d={POST(x)}
fill={wood}
/>
<path
d={POST(x)}
fill={`url(#${id}w)`}
/>
</g>
))}
<g clipPath={`url(#${id}c)`}>
{[sand, `url(#${id}g)`].map((fill) => (
<g key={fill} fill={fill}>
{left > 0 && <path d={`M36 ${topY} Q80 ${topY + dip * 2} 124 ${topY} L124 104 L36 104 Z`} />}
<path d={`M36 ${floor} L36 ${edge} Q80 ${2 * peak - edge} 124 ${edge} L124 ${floor} Z`} />
</g>
))}
<rect x="36" y="20" width="88" height="160" fill={`url(#${id}s)`} />
{running && left > 0 && (
<>
<line x1="80" y1="98" x2="80" y2={peak} stroke={sand} strokeWidth="1.8" />
{GRAINS.map((i) => {
const t = (p * 26 + i / GRAINS.length) % 1;
return <circle key={i} cx={80 + Math.sin(i * 2.4) * 3 * t} cy={102 + t * fall} r="0.9" fill={sand} />;
})}
</>
)}
</g>
<path d={GLASS} fill="#fff" fillOpacity="0.05" stroke="#fff" strokeOpacity="0.55" strokeWidth="1.8" strokeLinejoin="round" />
<path d="M54 34 C54 62 66 76 73 88" fill="none" stroke="#fff" strokeOpacity="0.5" strokeWidth="3" strokeLinecap="round" />
<path d="M106 166 C106 138 94 124 87 112" fill="none" stroke="#fff" strokeOpacity="0.5" strokeWidth="3" strokeLinecap="round" />
{[6, 176].map((y) => (
<g key={y}>
<rect x="10" y={y} width="140" height="18" rx="6" fill={wood} />
<rect x="10" y={y} width="140" height="18" rx="6" fill={`url(#${id}w)`} />
<rect x="10" y={y === 6 ? 19 : 176} width="140" height="3" fill="#d9b163" opacity="0.8" />
</g>
))}
</g>
</svg>
</div>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/hour-sand.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/hour-sand-demo.tsx
import { HourSand } from "@/components/ui/hour-sand"; export default function HourSandExample() { return ( <div className="@container-size grid h-full place-items-center overflow-hidden bg-[radial-gradient(circle_at_50%_45%,#2c2436,#0f0c14_75%)] p-2"> <HourSand className="w-[min(96cqh,88cqw,300px)]" /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| sand | string | "#e0a955" | Colour of the sand. |
| wood | string | "#7a4a26" | Colour of the wooden frame. |
| seconds | number | 6 | Seconds for one full turn, before speed. |
| speed | number | 1 | 1 is the default pace; 2 is twice as fast. |
| label | string | "Loading" | Read out by screen readers (role=status). |
| className | string | "w-48" | Set the width here. Everything scales with it. |