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.

Usage

registry/core/cylinder-hillndale/demo.tsx
"use client";

import { CylinderHillndale } from "./component";

export default function CylinderHillndaleDemo() {
  return (
    <div className="flex min-h-screen w-full flex-col items-center justify-center gap-10 bg-background px-8 py-10">
      <div className="w-full max-w-3xl">
        <h2 className="mb-3 font-mono text-sm text-foreground">Section one</h2>
        <p className="mb-8 max-w-prose font-mono text-xs text-ns-muted">
          A rotating cylinder and its lead-screw-driven stylus carriage
          stand in for a plain rule, same idea as{" "}
          <code>&lt;hr /&gt;</code> — rotation and axial travel locked
          together, one wrap per turn.
        </p>
        <CylinderHillndale />
      </div>
      <div className="w-full max-w-3xl">
        <h2 className="mt-8 font-mono text-sm text-foreground">Section two</h2>
      </div>
    </div>
  );
}

Component

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>
  );
}

The usage example imports ./component because it sits next to the component file in this repo. Installed, that file lands at components/ui/cylinder-hillndale.tsx, so point the import there.

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.

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?string—extra classes merged onto the rendered root element