Skip to main content

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.json

Ask AI

Point an assistant at this component's docs (llms-full.txt) with one click.

Claude, ChatGPT, Grok, and Perplexity open with the prompt already in. Gemini copies it to your clipboard first. Paste it in once the chat opens.

Source
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

PropTypeDefaultDescription
height?number88band height in px; the cylinder's radius derives from this. Default 88.
className?stringextra classes merged onto the rendered root element