Hourglass
import { useId, useState } from 'react';
const BULB = 'M12 8H88C88 42 56 50 52 66C56 82 88 90 88 124H12C12 90 44 82 48 66C44 50 12 42 12 8Z';
const css = `
@keyframes slider-hourglass-fall { to { stroke-dashoffset: -8; } }
.slider-hourglass { display: flex; align-items: center; gap: 22px; }
.slider-hourglass__body { position: relative; width: 96px; height: 150px; }
.slider-hourglass__body svg { display: block; width: 100%; height: 100%; overflow: visible; }
.slider-hourglass__cap { fill: light-dark(#6d7179, #7d8087); }
.slider-hourglass__post { fill: light-dark(#cbced3, #393c41); }
.slider-hourglass__glass { fill: color-mix(in oklab, light-dark(#50535b, #c6c8cd) 8%, transparent); stroke: light-dark(#5f626a, #a7abb2); stroke-width: 2; }
.slider-hourglass__sand { fill: light-dark(#42454c, #e5e6e9); }
.slider-hourglass__stream { stroke: light-dark(#42454c, #e5e6e9); stroke-width: 1.6; stroke-dasharray: 3 1; animation: slider-hourglass-fall 0.25s linear infinite; }
.slider-hourglass:has(.slider-hourglass__input:focus-visible) .slider-hourglass__glass { stroke: light-dark(#373a40, #f1f2f3); stroke-width: 3; }
.slider-hourglass__input {
position: absolute;
left: 50%;
top: 50%;
width: 150px;
height: 96px;
margin: 0;
transform: translate(-50%, -50%) rotate(-90deg);
opacity: 0;
cursor: ns-resize;
-webkit-appearance: none;
appearance: none;
background: none;
}
.slider-hourglass__input::-webkit-slider-thumb { -webkit-appearance: none; width: 10px; height: 96px; }
.slider-hourglass__input::-moz-range-thumb { width: 10px; height: 96px; border: 0; }
.slider-hourglass__read { display: grid; gap: 2px; }
.slider-hourglass__min { color: light-dark(#373a40, #f1f2f3); font-size: 36px; font-weight: 300; letter-spacing: -0.03em; font-variant-numeric: tabular-nums; line-height: 1; }
.slider-hourglass__unit { color: light-dark(#5f626a, #a7abb2); font-size: 11px; font-weight: 600; letter-spacing: 0.08em; }
@media (prefers-reduced-motion: reduce) {
.slider-hourglass__stream { animation: none; }
}
`;
export function HourglassSlider() {
const [min, setMin] = useState(25);
const clip = `slider-hourglass-${useId().replace(/[^a-zA-Z0-9]/g, '')}`;
const t = min / 60;
// Sand left in the top bulb sits on its narrow neck; what has run out piles up at the bottom.
const top = (64 - t * 48).toFixed(1);
const pile = ((1 - t) * 44).toFixed(1);
return (
<>
<style>
{css}
</style>
<div className="slider-hourglass">
<span className="slider-hourglass__body">
<svg viewBox="0 0 100 132" aria-hidden="true">
<defs>
<clipPath id={clip}>
<path d={BULB} />
</clipPath>
</defs>
<rect className="slider-hourglass__post" x="8" y="6" width="4" height="120" rx="2" />
<rect className="slider-hourglass__post" x="88" y="6" width="4" height="120" rx="2" />
<g clipPath={`url(#${clip})`}>
<rect className="slider-hourglass__sand" x="0" y={top} width="100" height={(66 - +top).toFixed(1)} />
<path className="slider-hourglass__sand" d={`M10 125Q50 ${(125 - +pile * 2).toFixed(1)} 90 125Z`} />
{min > 0 && <line className="slider-hourglass__stream" x1="50" y1="64" x2="50" y2="126" />}
</g>
<path className="slider-hourglass__glass" d={BULB} />
<rect className="slider-hourglass__cap" x="2" y="0" width="96" height="8" rx="3" />
<rect className="slider-hourglass__cap" x="2" y="124" width="96" height="8" rx="3" />
</svg>
<input
className="slider-hourglass__input"
type="range"
min={0}
max={60}
value={min}
onChange={(e) => setMin(+e.target.value)}
aria-label="Timer"
aria-valuetext={`${min} minutes`}
/>
</span>
<span className="slider-hourglass__read" aria-hidden="true">
<span className="slider-hourglass__min">{min}</span>
<span className="slider-hourglass__unit">MINUTES</span>
</span>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initSliderHourglass(el) on the mounted element instead.
<div class="slider-hourglass">
<span class="slider-hourglass__body">
<svg viewBox="0 0 100 132" aria-hidden="true">
<defs>
<clipPath id="slider-hourglass-def">
<path d="M12 8H88C88 42 56 50 52 66C56 82 88 90 88 124H12C12 90 44 82 48 66C44 50 12 42 12 8Z"></path>
</clipPath>
</defs>
<rect class="slider-hourglass__post" x="8" y="6" width="4" height="120" rx="2"></rect>
<rect class="slider-hourglass__post" x="88" y="6" width="4" height="120" rx="2"></rect>
<g clip-path="url(#slider-hourglass-def)">
<rect class="slider-hourglass__sand" x="0" y="44.0" width="100" height="22.0"></rect>
<path class="slider-hourglass__sand" d="M10 125Q50 73.6 90 125Z"></path>
<line class="slider-hourglass__stream" x1="50" y1="64" x2="50" y2="126"></line>
</g>
<path class="slider-hourglass__glass" d="M12 8H88C88 42 56 50 52 66C56 82 88 90 88 124H12C12 90 44 82 48 66C44 50 12 42 12 8Z"></path>
<rect class="slider-hourglass__cap" x="2" y="0" width="96" height="8" rx="3"></rect>
<rect class="slider-hourglass__cap" x="2" y="124" width="96" height="8" rx="3"></rect>
</svg>
<input class="slider-hourglass__input" type="range" min="0" max="60" aria-label="Timer" aria-valuetext="25 minutes" value="25"/>
</span>
<span class="slider-hourglass__read" aria-hidden="true">
<span class="slider-hourglass__min">25</span>
<span class="slider-hourglass__unit">MINUTES</span>
</span>
</div>@keyframes slider-hourglass-fall { to { stroke-dashoffset: -8; } }
.slider-hourglass { display: flex; align-items: center; gap: 22px; }
.slider-hourglass__body { position: relative; width: 96px; height: 150px; }
.slider-hourglass__body svg { display: block; width: 100%; height: 100%; overflow: visible; }
.slider-hourglass__cap { fill: light-dark(#6d7179, #7d8087); }
.slider-hourglass__post { fill: light-dark(#cbced3, #393c41); }
.slider-hourglass__glass { fill: color-mix(in oklab, light-dark(#50535b, #c6c8cd) 8%, transparent); stroke: light-dark(#5f626a, #a7abb2); stroke-width: 2; }
.slider-hourglass__sand { fill: light-dark(#42454c, #e5e6e9); }
.slider-hourglass__stream { stroke: light-dark(#42454c, #e5e6e9); stroke-width: 1.6; stroke-dasharray: 3 1; animation: slider-hourglass-fall 0.25s linear infinite; }
.slider-hourglass:has(.slider-hourglass__input:focus-visible) .slider-hourglass__glass { stroke: light-dark(#373a40, #f1f2f3); stroke-width: 3; }
.slider-hourglass__input {
position: absolute;
left: 50%;
top: 50%;
width: 150px;
height: 96px;
margin: 0;
transform: translate(-50%, -50%) rotate(-90deg);
opacity: 0;
cursor: ns-resize;
-webkit-appearance: none;
appearance: none;
background: none;
}
.slider-hourglass__input::-webkit-slider-thumb { -webkit-appearance: none; width: 10px; height: 96px; }
.slider-hourglass__input::-moz-range-thumb { width: 10px; height: 96px; border: 0; }
.slider-hourglass__read { display: grid; gap: 2px; }
.slider-hourglass__min { color: light-dark(#373a40, #f1f2f3); font-size: 36px; font-weight: 300; letter-spacing: -0.03em; font-variant-numeric: tabular-nums; line-height: 1; }
.slider-hourglass__unit { color: light-dark(#5f626a, #a7abb2); font-size: 11px; font-weight: 600; letter-spacing: 0.08em; }
@media (prefers-reduced-motion: reduce) {
.slider-hourglass__stream { animation: none; }
}function initSliderHourglass(root) {
var input = root.querySelector('.slider-hourglass__input');
var sand = root.querySelectorAll('.slider-hourglass__sand');
var stream = root.querySelector('.slider-hourglass__stream');
var read = root.querySelector('.slider-hourglass__min');
function update() {
var min = +input.value;
var t = min / 60;
// Sand left in the top bulb sits on its narrow neck; what has run out piles up at the bottom.
var top = 64 - t * 48;
var pile = (1 - t) * 44;
sand[0].setAttribute('y', top.toFixed(1));
sand[0].setAttribute('height', (66 - top).toFixed(1));
sand[1].setAttribute('d', 'M10 125Q50 ' + (125 - pile * 2).toFixed(1) + ' 90 125Z');
stream.style.display = min > 0 ? '' : 'none';
read.textContent = min;
input.setAttribute('aria-valuetext', min + ' minutes');
}
input.addEventListener('input', update);
update();
return function () { input.removeEventListener('input', update); };
}
document.querySelectorAll('.slider-hourglass').forEach(initSliderHourglass);Goal: Create a "Hourglass" slider.
Visual behavior: A timer slider as an hourglass: drag up to add minutes and the sand flows back into the top bulb, while the stream keeps trickling into the pile below.
Technique:
- A real <input type="range"> lies over the drawing, transparent and rotated a quarter turn (-90deg), so dragging up raises the value and the arrow, Page Up/Down, Home and End keys all work natively. The drawing is aria-hidden.
- The glass outline is also an SVG clipPath; a rectangle of sand in the top bulb and a curved pile at the bottom are recalculated from the minutes, so both are always cut to the glass.
- The falling stream is a dashed line whose dash offset loops, and it disappears at zero minutes.
- The range runs 0 to 60 and the aria-valuetext reads the minutes.
Accessibility: A native range input with an aria-label and an aria-valuetext in words, so a screen reader announces the reading rather than a bare number; keyboard focus outlines the drawn thumb. Under prefers-reduced-motion the stream stands still.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Hourglass" slider.
Visual behavior: A timer slider as an hourglass: drag up to add minutes and the sand flows back into the top bulb, while the stream keeps trickling into the pile below.
Technique:
- A real <input type="range"> lies over the drawing, transparent and rotated a quarter turn (-90deg), so dragging up raises the value and the arrow, Page Up/Down, Home and End keys all work natively. The drawing is aria-hidden.
- The glass outline is also an SVG clipPath; a rectangle of sand in the top bulb and a curved pile at the bottom are recalculated from the minutes, so both are always cut to the glass.
- The falling stream is a dashed line whose dash offset loops, and it disappears at zero minutes.
- The range runs 0 to 60 and the aria-valuetext reads the minutes.
Accessibility: A native range input with an aria-label and an aria-valuetext in words, so a screen reader announces the reading rather than a bare number; keyboard focus outlines the drawn thumb. Under prefers-reduced-motion the stream stands still.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
About this component
A timer slider as an hourglass: drag up to add minutes and the sand flows back into the top bulb, while the stream keeps trickling into the pile below.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- state change, fill, loop
- Techniques
- svg, keyframes, stroke dashoffset
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A native range input with an aria-label and an aria-valuetext in words, so a screen reader announces the reading rather than a bare number; keyboard focus outlines the drawn thumb. Under prefers-reduced-motion the stream stands still.