ns-ui
Cylinder Hill-and-Dale
A full-width section divider rendered as a side elevation of an Edison-style wax cylinder phonograph: a rotating cylinder carrying a static helical hill-and-dale groove, a lead-screw rail, and a stylus carriage the lead-screw advances axially in exact lockstep with rotation, one wrap of travel per one full turn, always, at any frame rate.
Use when Pick cylinder-hillndale when the divider's mechanic should be a helical rotation-locked-to-translation motif — a carriage that visibly steps sideways by exactly one wrap per cylinder turn. Pick divider-petscii-vu instead when the divider should read as a live reverse-video amplitude meter with no rotational geometry at all, and loader-thread-spool instead when the surface is a loading/progress indicator winding radially outward as a top-down spiral rather than a side-elevation helix with no radial growth.
Install
npx shadcn add https://design.helpmarq.com /r/cylinder-hillndale.jsonSource
registry/core/cylinder-hillndale/component.tsx"use client";
import { useEffect, useRef } from "react";
// ---------------------------------------------------------------------------
// CylinderHillndale — a full-width section divider rendered as a side
// elevation of an Edison-style cylinder phonograph: a rotating wax cylinder
// carrying a helical "hill-and-dale" groove (depth-modulated, not the later
// lateral-cut vinyl groove), a lead-screw rail above it, and a stylus
// carriage that the lead-screw advances axially in EXACT lockstep with the
// cylinder's rotation — one wrap of axial travel per one full turn, always,
// at any frame rate. That lockstep is the entire mechanic: the carriage's
// horizontal creep along the rail and the stylus arm's vertical bob (which
// tracks the groove's hill/dale height at the carriage's own position) are
// both driven off the SAME accumulated rotation angle, so the two motions
// can never drift apart.
//
// Discriminator against loader-thread-spool (this repo's nearest neighbour
// in "something winds/turns"): that component is a top-down concentric
// coil winding radially outward, a spiral. This is a side elevation of a
// horizontal cylinder — a straight helix wrapping a fixed-diameter barrel,
// axial translation locked to rotation, no radial growth, no spool. The
// groove pattern itself never animates; only the carriage/stylus and the
// end-cap rotation tick move.
// ---------------------------------------------------------------------------
const ROTATION_PERIOD_S = 2.6; // rendered seconds per cylinder revolution
const NUM_WRAPS = 10; // wrap crossings across one full traversal
const TRAVERSAL_S = NUM_WRAPS * ROTATION_PERIOD_S; // 26s, matches the real Edison 4" cylinder pass
const END_CAP_RX_FACTOR = 0.22; // end-cap ellipse half-width, relative to radius
const AMPL_FACTOR = 0.62; // groove sine amplitude, relative to radius
const RAIL_GAP_FACTOR = 0.85; // lead-screw rail height above cylinder center, relative to radius
// baked "hill-and-dale" depth envelope — a fixed spatial pattern cut into
// the groove, three non-commensurate components so it never repeats
// visibly across a single traversal. A function of AXIAL POSITION only,
// never of time: the groove doesn't re-record itself, the carriage just
// rides over whatever depth was already cut there.
function grooveDepth(xFrac: number) {
const a =
0.5 +
0.5 *
Math.sin(2 * Math.PI * 3.1 * xFrac) *
Math.cos(2 * Math.PI * 1.7 * xFrac + 0.6);
const b = 0.5 + 0.5 * Math.sin(2 * Math.PI * 7.3 * xFrac + 2.1);
return Math.max(0, Math.min(1, 0.65 * a + 0.35 * b));
}
export interface CylinderHillndaleProps {
/** band height in px; the cylinder's radius derives from this. Default 88. */
height?: number;
/** extra classes merged onto the rendered root element */
className?: string;
}
export function CylinderHillndale({
height = 88,
className = "",
}: CylinderHillndaleProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let fg = "currentColor";
let border = "currentColor";
const readTokens = () => {
const root = getComputedStyle(document.documentElement);
fg = root.getPropertyValue("--foreground").trim() || "currentColor";
border = root.getPropertyValue("--border").trim() || "currentColor";
};
let width = 0;
let dpr = 1;
let sized = false;
// layout, recomputed on resize
let cx = { left: 0, right: 0 }; // cylinder body x-extent
let cy = 0; // vertical center of the cylinder
let radius = 0;
let railY = 0;
let wrapSpacing = 1;
let ampl = 1;
const layout = () => {
const rect = canvas.getBoundingClientRect();
width = rect.width;
if (width < 2) {
sized = false;
return;
}
dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.max(1, Math.round(width * dpr));
canvas.height = Math.max(1, Math.round(height * dpr));
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
radius = height * 0.32;
const endCapRx = radius * END_CAP_RX_FACTOR;
const inset = endCapRx + radius * 0.15;
cx = { left: inset, right: width - inset };
cy = height * 0.58; // slightly below center so the rail above has room
railY = cy - radius * RAIL_GAP_FACTOR - radius * 0.55;
ampl = radius * AMPL_FACTOR;
const bodyLen = Math.max(1, cx.right - cx.left);
wrapSpacing = bodyLen / NUM_WRAPS;
sized = true;
};
const grooveY = (x: number) => {
const frac = (x - cx.left) / Math.max(1, cx.right - cx.left);
return cy + ampl * Math.sin((2 * Math.PI * (x - cx.left)) / wrapSpacing) * (0.75 + 0.25 * grooveDepth(frac));
};
const draw = (elapsedS: number) => {
if (!sized) return;
ctx.clearRect(0, 0, width, height);
const bodyLen = cx.right - cx.left;
const endCapRx = radius * END_CAP_RX_FACTOR;
// -- cylinder body outline -------------------------------------------
ctx.strokeStyle = fg;
ctx.lineWidth = 1.25;
ctx.beginPath();
ctx.moveTo(cx.left, cy - radius);
ctx.lineTo(cx.right, cy - radius);
ctx.moveTo(cx.left, cy + radius);
ctx.lineTo(cx.right, cy + radius);
ctx.stroke();
// rotation phase, unbounded — everything below derives from this
const revolutions = elapsedS / ROTATION_PERIOD_S;
const rotationAngle = revolutions * 2 * Math.PI;
const traversalFrac = (elapsedS / TRAVERSAL_S) % 1;
const carriageX = cx.left + traversalFrac * bodyLen;
// -- end caps, each with a rotation tick ------------------------------
for (const ex of [cx.left, cx.right]) {
ctx.strokeStyle = fg;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.ellipse(ex, cy, endCapRx, radius, 0, 0, Math.PI * 2);
ctx.stroke();
// tick marking cylinder rotational phase — the "barrel is turning"
// cue, kept visually separate from the carriage's own motion
const tickAngle = rotationAngle % (2 * Math.PI);
const tx = ex + endCapRx * 0.72 * Math.sin(tickAngle);
const ty = cy + radius * 0.72 * Math.cos(tickAngle);
ctx.beginPath();
ctx.arc(tx, ty, 1.4, 0, Math.PI * 2);
ctx.fillStyle = fg;
ctx.fill();
}
// -- helical groove, static in space, depth-modulated stroke width --
// drawn as short segments so lineWidth can vary along its length —
// width encodes hill/dale depth, never a color or alpha shift
const STEP = Math.max(2, wrapSpacing / 14);
ctx.strokeStyle = fg;
let prevX = cx.left;
let prevY = grooveY(cx.left);
for (let x = cx.left + STEP; x <= cx.right + STEP; x += STEP) {
const clampedX = Math.min(x, cx.right);
const yv = grooveY(clampedX);
const frac = (clampedX - cx.left) / bodyLen;
ctx.lineWidth = 0.8 + grooveDepth(frac) * 1.8;
ctx.beginPath();
ctx.moveTo(prevX, prevY);
ctx.lineTo(clampedX, yv);
ctx.stroke();
prevX = clampedX;
prevY = yv;
if (clampedX >= cx.right) break;
}
// -- lead-screw rail: a true separator, never a fill -----------------
ctx.strokeStyle = border;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(cx.left, railY);
ctx.lineTo(cx.right, railY);
ctx.stroke();
// -- carriage: pure horizontal translation along the rail ------------
const carriageW = Math.max(6, radius * 0.36);
const carriageH = Math.max(4, radius * 0.24);
ctx.fillStyle = fg;
ctx.fillRect(
carriageX - carriageW / 2,
railY - carriageH / 2,
carriageW,
carriageH
);
// -- stylus arm: vertical link from carriage down to the groove ------
// its length is exactly the groove's local height under the current
// rotation phase — the one visible proof rotation and translation
// are locked, not two independent animations
const stylusY = grooveY(carriageX);
ctx.strokeStyle = fg;
ctx.lineWidth = 1.1;
ctx.beginPath();
ctx.moveTo(carriageX, railY + carriageH / 2);
ctx.lineTo(carriageX, stylusY);
ctx.stroke();
ctx.beginPath();
ctx.arc(carriageX, stylusY, 1.8, 0, Math.PI * 2);
ctx.fill();
};
// -- loop ----------------------------------------------------------------
let raf = 0;
let clockS = 0; // accumulated elapsed seconds, pauses cleanly on hide
let lastTs = 0;
const loop = (now: number) => {
const dt = lastTs ? Math.min(0.25, (now - lastTs) / 1000) : 1 / 60;
lastTs = now;
clockS += dt;
draw(clockS);
if (!document.hidden) raf = requestAnimationFrame(loop);
};
const start = () => {
cancelAnimationFrame(raf);
lastTs = 0;
raf = requestAnimationFrame(loop);
};
const mo = new MutationObserver(() => {
readTokens();
if (reduced) draw(TRAVERSAL_S * 0.4);
});
mo.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
const ro = new ResizeObserver(() => {
layout();
if (reduced) draw(TRAVERSAL_S * 0.4);
else if (sized && !document.hidden) start();
});
ro.observe(canvas);
let io: IntersectionObserver | null = null;
if (!reduced) {
io = new IntersectionObserver(
(entries) => {
const visible = entries[0]?.isIntersecting;
if (visible && sized && !document.hidden) {
start();
} else {
cancelAnimationFrame(raf);
}
},
{ threshold: 0.01 }
);
io.observe(canvas);
}
const onVis = () => {
cancelAnimationFrame(raf);
if (!document.hidden && !reduced && sized) start();
};
document.addEventListener("visibilitychange", onVis);
document.fonts.ready.then(() => {
readTokens();
layout();
if (!sized) return;
if (reduced) {
// deliberately non-t0, most-structured frame: traversal progress
// 40% — the carriage is clearly past the start and the groove's
// depth-modulated stroke width is visibly non-uniform around it
draw(TRAVERSAL_S * 0.4);
return;
}
draw(0);
start();
});
return () => {
cancelAnimationFrame(raf);
mo.disconnect();
ro.disconnect();
io?.disconnect();
document.removeEventListener("visibilitychange", onVis);
};
}, [height]);
return (
<div
role="separator"
aria-orientation="horizontal"
className={`ns-cyl w-full ${className}`}
>
<canvas
ref={canvasRef}
aria-hidden="true"
className="block w-full text-foreground"
style={{ height }}
/>
</div>
);
}
Build spec
Build <CylinderHillndale height? className?> as a full-width <canvas> band wrapped in a <div role="separator" aria-orientation="horizontal">, a drop-in replacement for <hr>/border-top between page sections. SOURCE, NOT INVENTED: early Edison cylinder phonographs recorded via a vertically-modulated ("hill-and-dale") helical groove cut into a rotating wax cylinder — the stylus rides groove DEPTH variation, not the side-to-side lateral modulation Berliner's later disc records used. A lead-screw advances the stylus carriage axially at a fixed pitch in exact lockstep with cylinder rotation, which is what physically produces one continuous helix rather than concentric rings. THE MECHANIC: a cylinder turns while a stylus carriage rides its helical groove, stepping sideways by exactly one wrap's width for every full turn — rotation and translation locked together, never independent. GEOMETRY: side elevation only. radius = height * 0.32; end-cap ellipses at each end (rx = radius * 0.22, ry = radius); the cylinder body's horizontal extent (its two long edge lines at cy ± radius) spans from just past the left end-cap to just before the right, and that span IS the full canvas width scaled by the container — 'cylinder length mapped proportionally to canvas width' per spec. wrapSpacing = bodyLength / NUM_WRAPS where NUM_WRAPS is fixed at 10 — this is what keeps the two documented real numbers (a 6px wrap pitch and a 26s full traversal) mutually consistent at any rendered width: wrapSpacing scales with the container so the ratio bodyLength/wrapSpacing (and therefore traversal duration) never drifts, while ROTATION_PERIOD_S stays a fixed real time constant (2.6s per revolution, decoupled from Edison's real 160rpm) that never scales with size. THE NON-NEGOTIABLE IDENTITY: carriageX derives from rotationAngle, never from an independently-integrated position — traversalFrac = (elapsedSeconds / (NUM_WRAPS * ROTATION_PERIOD_S)) % 1; carriageX = bodyLeft + traversalFrac * bodyLength. Because this is computed directly from accumulated elapsed time every frame (not incremented per-frame and summed), the relationship 'one full wrap of translation exactly matches one full revolution' holds exactly regardless of frame rate or dropped frames — there is no drift to accumulate. THE GROOVE is a STATIC spatial curve, never animated over time: grooveY(x) = cy + amplitude * sin(2π * (x - bodyLeft) / wrapSpacing), amplitude = radius * 0.62, drawn once per frame across the whole body length as a sequence of short strokes (STEP ≈ wrapSpacing / 14) so each segment's lineWidth can independently encode a baked hill-and-dale DEPTH envelope — grooveDepth(xFraction), a fixed sum of two non-commensurate sine components (frequencies 3.1 and 7.3 cycles across the span, phase-offset) normalized 0..1 and mapped to lineWidth = 0.8 + depth * 1.8px. Depth is a function of axial position only, never of time or rotation phase — the groove doesn't re-record itself, the carriage just rides over whatever was already cut there, which is why the 'groove-depth shading pattern under it' visibly changes as the carriage's x advances, not as the clock ticks in place. STYLUS: the carriage itself is a small filled rect that translates PURELY HORIZONTALLY along a lead-screw rail line drawn above the cylinder body (at cy - radius * 1.4, stroked in --border as a true separator, never a fill); a thin --foreground stylus arm drops from the carriage down to grooveY(carriageX) — that arm's LENGTH is the one visible proof the two motions are locked, since it is computed from the exact same carriageX the carriage block itself uses, not a second independent oscillator. END CAPS: each end-cap ellipse carries a small filled tick at radius * 0.72 from center, angle = (elapsedSeconds / ROTATION_PERIOD_S * 2π) mod 2π — the explicit 'the barrel itself is turning' cue, kept visually and computationally separate from the carriage's translation so a viewer can confirm rotation is happening even while staring only at the carriage. RESTING LOOP: t0 is some arbitrary carriage axial position and rotational tick angle; at 2.5s (2.5s / 2.6s-per-rev ≈ one wrap crossing has just completed) the carriage sits roughly one full wrapSpacing further along the rail, a visibly different axial position, with a different groove-depth pattern under the stylus tip; at 5s the carriage has advanced roughly two wraps further still, clearly past its t0 position. Full traversal (NUM_WRAPS * ROTATION_PERIOD_S = 26s) wraps via modulo, producing the carriage's instant snap back to bodyLeft with a fresh pass beginning — an unbounded loop, never a process that finishes and stops. TOKENS: fg reads --foreground and border reads --border, both via getComputedStyle(document.documentElement), re-derived on a documentElement class MutationObserver so a theme flip is live; --ns-accent never appears anywhere — there is no interactive surface on a pure ambient divider, so it is never reserved for anything. --border is used exactly once, for the lead-screw rail line, and strictly as a separator stroke, never as a fill or the groove's own ink (the groove and cylinder outline are --foreground only, since --border measures ~1.1:1 in light theme and would make the groove's depth modulation structurally invisible). A ResizeObserver on the canvas re-lays-out and restarts the loop on container size changes; an IntersectionObserver pauses the rAF loop while off-screen and resumes it with a fresh last-timestamp (no giant delta-time jump) on re-entry. The loop also pauses on document.hidden and resumes cleanly on visibilitychange. prefers-reduced-motion skips the rAF loop entirely and draws exactly one frame at elapsedSeconds = TRAVERSAL_S * 0.4 — the carriage clearly past the start (not an ambiguous 'just began' t0 frame) with the groove's depth-modulated stroke width visibly non-uniform under and near the stylus tip. Direct-DOM rAF, zero React state on the hot path, zero dependencies. A11Y: role=separator carries the divider's semantics with no accessible name required; the canvas is aria-hidden decoration; there is no keyboard surface because there is nothing to operate, so the registry's tab-reachability check is correctly skipped. Props: height (band height px, default 88; radius and every other proportion derive from it), className.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| height? | number | 88 | band height in px; the cylinder's radius derives from this. Default 88. |
| className? | string | — | extra classes merged onto the rendered root element |