Text Hourglass
A functional hourglass timer — text shaped as an hourglass with sand trickling through.
This demo shapes text into an hourglass silhouette using per-line variable width layout. The top half is an inverted triangle (wide to narrow) and the bottom is a regular triangle (narrow to wide). A timer mode animates text fading from top to bottom, simulating sand falling through the neck.
prepareWithSegments()layoutNextLine() What this demonstrates
Per-line variable-width layout shaped by a mathematical function. Each line's available width is computed from its vertical position to create an hourglass silhouette. The timer mode adds animation by fading characters between the two halves.
Relevant Pretext API
prepareWithSegments(text, font)— prepare text for each halflayoutNextLine(prepared, cursor, maxWidth)— variable width per line based on Y position
Hourglass geometry
The width at any Y position is: neckWidth + |y - midY| / midY * (maxWidth - neckWidth).
This creates the characteristic pinch at the center. Each half uses its own prepared text
to fill its triangle independently.
Quick start
import { prepareWithSegments, layoutNextLine, buildFont } from '@chenglou/pretext';
// --- Hourglass width function ---
// Wide at top/bottom edges, pinched at the center "neck"
function getWidthAtY(y, height, maxW, neckW) {
const midY = height / 2;
const dist = Math.abs(y - midY) / midY; // 0 at center, 1 at edges
return neckW + dist * (maxW - neckW); // neckW..maxW
}
// --- Layout parameters ---
const fontSize = 14;
const lineHeight = Math.round(fontSize * 1.6);
const hourglassHeight = 550;
const neckWidth = 100; // narrowest point
const maxWidth = 600; // widest point (top & bottom edges)
const halfHeight = hourglassHeight / 2;
const font = buildFont(fontSize);
// --- Top half: wide at top, narrow at middle ---
const preparedTop = prepareWithSegments(topText, font);
let cursorTop = { segmentIndex: 0, graphemeIndex: 0 };
let topLines = [], y = 0;
while (y + lineHeight <= halfHeight) {
const w = getWidthAtY(y, hourglassHeight, maxWidth, neckWidth);
const availW = Math.max(40, w - 16); // small margin
const line = layoutNextLine(preparedTop, cursorTop, availW);
if (!line) break;
const xOffset = (maxWidth - availW) / 2; // center within hourglass
topLines.push({ text: line.text, x: xOffset, y, width: availW });
cursorTop = line.end;
y += lineHeight;
}
// --- Bottom half: narrow at middle, wide at bottom ---
const preparedBottom = prepareWithSegments(bottomText, font);
let cursorBottom = { segmentIndex: 0, graphemeIndex: 0 };
let bottomLines = [];
y = halfHeight; // start at the neck
while (y + lineHeight <= hourglassHeight) {
const w = getWidthAtY(y, hourglassHeight, maxWidth, neckWidth);
const availW = Math.max(40, w - 16);
const line = layoutNextLine(preparedBottom, cursorBottom, availW);
if (!line) break;
bottomLines.push({ text: line.text, x: (maxWidth - availW) / 2, y });
cursorBottom = line.end;
y += lineHeight;
}
// --- Timer mode: fade lines to simulate sand falling ---
// Alpha per line based on elapsed progress (0..1)
const fadedCount = Math.floor(progress * topLines.length * 2);
topLines.forEach((line, i) => {
line.alpha = i < fadedCount ? Math.max(0.1, 1 - progress * 1.5) : 1;
});
// --- SVG outline path (smooth hourglass curve) ---
const points = [];
for (let i = 0; i <= 60; i++) {
const py = (i / 60) * hourglassHeight;
const w = getWidthAtY(py, hourglassHeight, maxWidth, neckWidth);
points.push(`L ${(maxWidth - w) / 2} ${py}`); // left edge
}
// Then trace right edge bottom-to-top to close the shape