# Rope Marquee (`rope-marquee`)

> A marquee strung on a real rope — type flows along a verlet-simulated line you can grab, yank, and pluck while it sways back into its swags.

- **Docs:** https://www.mellowui.com/components/rope-marquee
- **Markdown:** https://www.mellowui.com/components/rope-marquee.md
- **Registry:** https://www.mellowui.com/r/rope-marquee.json
- **Tool prompt:** https://www.mellowui.com/api/prompt/rope-marquee
- **Categories:** display, marquee, physics, animation
- **Dependencies:** none

## AI prompt

Add a RopeMarquee component from the mellow library — a marquee whose type flows along a verlet-simulated rope. Pass `text`, and tune `speed`, `pins` (pinned points, 2 is one swag), `slack`, `gravity` and `height`. Set `interactive` to let visitors grab and pluck the rope.

## Install

```bash
npx shadcn@latest add https://mellowui.com/r/rope-marquee.json
```

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `text` | `string` | — | The line of type strung on the rope. Repeated to fill it. |
| `speed` | `number` | `42` | Flow speed in px/s along the rope. Negative flows the other way. |
| `pins` | `number` | `3` | Pinned points along the rope — 2 is one swag, 3 a double swag, and so on. |
| `slack` | `number` | `0.08` | Extra rope length as a fraction of width — more slack, deeper sag. |
| `height` | `number` | `190` | Container height in px. |
| `gravity` | `number` | `1500` | Downward pull in px/s² — higher snaps back harder. |
| `interactive` | `boolean` | `true` | Allow grabbing and plucking the rope. |
| `textClassName` | `string` | `"font-serif text-[26px] italic"` | Classes for the glyphs — font, size, fill. |
| `className` | `string` | — | Additional CSS classes. |

## Design notes

- Client component (`"use client"`) — required for animation and refs.
- Uses Mellow design tokens: `--ink`, `--ink-rgb`, `--background`, `--background-rgb`, `--rule`, `--font-sans`, `--font-serif`, `--font-mono`. Tokens auto-flip between light and dark themes — never hardcode colors.
- Respects `prefers-reduced-motion: reduce` where applicable.
- Self-contained — no shared utilities. Copy-paste, not a package.

## Source

```tsx
"use client";

import React, { useEffect, useId, useRef, useState } from "react";

export interface RopeMarqueeProps {
  /** The line of type strung on the rope. Repeated to fill it. */
  text: string;
  /** Flow speed in px/s along the rope. Negative flows the other way. */
  speed?: number;
  /** Pinned points along the rope — 2 is one swag, 3 a double swag, and so on. */
  pins?: number;
  /** Extra rope length as a fraction of width — more slack, deeper sag. */
  slack?: number;
  /** Container height in px. */
  height?: number;
  /** Downward pull in px/s² — higher snaps back harder. */
  gravity?: number;
  /** Allow grabbing and plucking the rope. */
  interactive?: boolean;
  /** Classes for the glyphs — font, size, fill. */
  textClassName?: string;
  className?: string;
  style?: React.CSSProperties;
}

const DT = 1 / 120;
const SEP = "  ·  ";

type Pt = { x: number; y: number; px: number; py: number; pin: boolean };

/**
 * A marquee strung on a real rope — type flows along a verlet-simulated
 * line pinned at two or more points. Grab the rope anywhere and yank it,
 * pluck it with the keyboard, and the text keeps flowing while the line
 * sways back into its swags.
 */
export function RopeMarquee({
  text,
  speed = 42,
  pins = 3,
  slack = 0.08,
  height = 190,
  gravity = 1500,
  interactive = true,
  textClassName = "font-serif text-[26px] italic",
  className,
  style,
}: RopeMarqueeProps) {
  const wrapRef = useRef<HTMLDivElement>(null);
  const pathRef = useRef<SVGPathElement>(null);
  const textPathRef = useRef<SVGTextPathElement>(null);
  const measureRef = useRef<SVGTextElement>(null);
  const speedRef = useRef(speed);
  speedRef.current = speed;
  const pluckRef = useRef<() => void>(() => {});
  const [reduced, setReduced] = useState(false);
  const [copyLen, setCopyLen] = useState(0);
  const [copies, setCopies] = useState(0);
  const uid = useId();
  const pathId = `rope-${uid.replace(/[^a-zA-Z0-9-]/g, "")}`;

  const copy = text + SEP;

  useEffect(() => {
    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    setReduced(mq.matches);
    const onChange = () => setReduced(mq.matches);
    mq.addEventListener("change", onChange);
    return () => mq.removeEventListener("change", onChange);
  }, []);

  // Measure one copy of the text; re-measure once webfonts land.
  useEffect(() => {
    let live = true;
    const measure = () => {
      if (live && measureRef.current) {
        setCopyLen(measureRef.current.getComputedTextLength());
      }
    };
    measure();
    document.fonts?.ready.then(measure);
    return () => {
      live = false;
    };
  }, [copy, textClassName]);

  useEffect(() => {
    if (!wrapRef.current || !pathRef.current || !copyLen) return;
    const wrap: HTMLDivElement = wrapRef.current;
    const path: SVGPathElement = pathRef.current;
    const pinCount = Math.max(2, Math.round(pins));

    let w = wrap.clientWidth || 600;
    let pts: Pt[] = [];
    let seg = 0;
    const anchorY = height * 0.3;

    const build = () => {
      w = wrap.clientWidth || 600;
      // Equal segment count per swag, or the swags sag unevenly.
      const swags = pinCount - 1;
      const perSwag = Math.max(4, Math.round(Math.min(40, Math.max(14, w / 40)) / swags));
      const n = perSwag * swags + 1;
      seg = (w * (1 + slack)) / (n - 1);
      pts = Array.from({ length: n }, (_, i) => {
        const x = (w * i) / (n - 1);
        return { x, y: anchorY, px: x, py: anchorY, pin: false };
      });
      for (let k = 0; k < pinCount; k++) {
        pts[Math.round(((n - 1) * k) / (pinCount - 1))].pin = true;
      }
      // Rope longer than its span must fill — estimate a generous ceiling.
      setCopies(Math.ceil((w * (1 + slack) * 1.4) / copyLen) + 1);
    };
    build();

    const draw = () => {
      let d = `M ${pts[0].x.toFixed(1)} ${pts[0].y.toFixed(1)}`;
      for (let i = 1; i < pts.length - 1; i++) {
        const mx = (pts[i].x + pts[i + 1].x) / 2;
        const my = (pts[i].y + pts[i + 1].y) / 2;
        d += ` Q ${pts[i].x.toFixed(1)} ${pts[i].y.toFixed(1)} ${mx.toFixed(1)} ${my.toFixed(1)}`;
      }
      const last = pts[pts.length - 1];
      d += ` L ${last.x.toFixed(1)} ${last.y.toFixed(1)}`;
      path.setAttribute("d", d);
    };

    let drag: { idx: number; tx: number; ty: number } | null = null;

    const step = () => {
      const g = gravity * DT * DT;
      for (const p of pts) {
        if (p.pin) continue;
        const vx = (p.x - p.px) * 0.99;
        const vy = (p.y - p.py) * 0.99;
        p.px = p.x;
        p.py = p.y;
        p.x += vx;
        p.y += vy + g;
      }
      for (let it = 0; it < 14; it++) {
        for (let k = 0, n = pts.length; k < pinCount; k++) {
          const p = pts[Math.round(((n - 1) * k) / (pinCount - 1))];
          p.x = (w * Math.round(((n - 1) * k) / (pinCount - 1))) / (n - 1);
          p.y = anchorY;
        }
        for (let i = 0; i < pts.length - 1; i++) {
          const a = pts[i];
          const b = pts[i + 1];
          const dx = b.x - a.x;
          const dy = b.y - a.y;
          const dist = Math.hypot(dx, dy) || 0.0001;
          const diff = (dist - seg) / dist;
          const wa = a.pin ? 0 : b.pin ? 1 : 0.5;
          const wb = b.pin ? 0 : a.pin ? 1 : 0.5;
          a.x += dx * diff * wa;
          a.y += dy * diff * wa;
          b.x -= dx * diff * wb;
          b.y -= dy * diff * wb;
        }
        if (drag && !pts[drag.idx].pin) {
          pts[drag.idx].x = drag.tx;
          pts[drag.idx].y = drag.ty;
        }
      }
    };

    if (reduced) {
      // Settle synchronously into the swags — static, no flow, no drag.
      for (let i = 0; i < 480; i++) step();
      draw();
      textPathRef.current?.setAttribute("startOffset", "0");
      const ro = new ResizeObserver(() => {
        build();
        for (let i = 0; i < 480; i++) step();
        draw();
      });
      ro.observe(wrap);
      return () => ro.disconnect();
    }

    let raf = 0;
    let running = false;
    let visible = true;
    let last = 0;
    let acc = 0;
    let offset = 0;

    const loop = (now: number) => {
      const dt = Math.min((now - last) / 1000, 1 / 30);
      last = now;
      acc += dt;
      while (acc >= DT) {
        step();
        acc -= DT;
      }
      draw();
      offset += speedRef.current * dt;
      offset = ((offset % copyLen) + copyLen) % copyLen;
      textPathRef.current?.setAttribute("startOffset", `${-offset}`);
      if (visible) raf = requestAnimationFrame(loop);
      else running = false;
    };
    const start = () => {
      if (running || !visible) return;
      running = true;
      last = performance.now();
      acc = 0;
      raf = requestAnimationFrame(loop);
    };

    const io = new IntersectionObserver(([entry]) => {
      visible = entry.isIntersecting;
      if (visible) start();
    });
    io.observe(wrap);

    let rect: DOMRect | null = null;
    const onDown = (e: PointerEvent) => {
      if (!interactive) return;
      rect = wrap.getBoundingClientRect();
      const x = e.clientX - rect.left;
      const y = e.clientY - rect.top;
      let best = -1;
      let bestD = 56;
      for (let i = 0; i < pts.length; i++) {
        if (pts[i].pin) continue;
        const d = Math.hypot(pts[i].x - x, pts[i].y - y);
        if (d < bestD) {
          bestD = d;
          best = i;
        }
      }
      if (best < 0) return;
      try {
        wrap.setPointerCapture(e.pointerId);
      } catch {
        /* synthetic pointer — capture unavailable */
      }
      drag = { idx: best, tx: x, ty: y };
      wrap.style.cursor = "grabbing";
      start();
    };
    const onMove = (e: PointerEvent) => {
      if (!drag || !rect) return;
      drag.tx = Math.max(-20, Math.min(w + 20, e.clientX - rect.left));
      drag.ty = Math.max(4, Math.min(height - 6, e.clientY - rect.top));
    };
    const onUp = () => {
      drag = null;
      wrap.style.cursor = interactive ? "grab" : "";
    };
    wrap.addEventListener("pointerdown", onDown);
    wrap.addEventListener("pointermove", onMove);
    wrap.addEventListener("pointerup", onUp);
    wrap.addEventListener("pointercancel", onUp);
    wrap.style.cursor = interactive ? "grab" : "";

    pluckRef.current = () => {
      for (const p of pts) {
        if (!p.pin) p.py = p.y - 12 - Math.random() * 8;
      }
      start();
    };

    const ro = new ResizeObserver(() => {
      drag = null;
      build();
      start();
    });
    ro.observe(wrap);

    draw();
    start();

    return () => {
      running = false;
      visible = false;
      cancelAnimationFrame(raf);
      io.disconnect();
      ro.disconnect();
      wrap.removeEventListener("pointerdown", onDown);
      wrap.removeEventListener("pointermove", onMove);
      wrap.removeEventListener("pointerup", onUp);
      wrap.removeEventListener("pointercancel", onUp);
      pluckRef.current = () => {};
    };
  }, [reduced, copyLen, pins, slack, height, gravity, interactive]);

  return (
    <div
      ref={wrapRef}
      role="group"
      tabIndex={interactive && !reduced ? 0 : undefined}
      aria-label={`${text} — type strung on a rope; drag it, or press space to pluck`}
      onKeyDown={(e) => {
        if ((e.key === " " || e.key === "Enter") && !e.repeat) {
          e.preventDefault();
          pluckRef.current();
        }
      }}
      className={[
        "relative w-full overflow-hidden outline-none select-none focus-visible:ring-2 focus-visible:ring-[rgba(var(--ink-rgb),0.3)]",
        className,
      ]
        .filter(Boolean)
        .join(" ")}
      style={{ height, touchAction: "pan-y", ...style }}
    >
      <svg aria-hidden="true" className="absolute inset-0 h-full w-full overflow-visible">
        <text
          ref={measureRef}
          xmlSpace="preserve"
          className={textClassName}
          style={{ visibility: "hidden", pointerEvents: "none" }}
        >
          {copy}
        </text>
        <path
          ref={pathRef}
          id={pathId}
          fill="none"
          style={{ stroke: "rgba(var(--ink-rgb),0.3)", strokeWidth: 1 }}
        />
        {copyLen > 0 && copies > 0 && (
          <text
            xmlSpace="preserve"
            className={textClassName}
            style={{ fill: "var(--ink)" }}
            dy="-5"
          >
            <textPath ref={textPathRef} href={`#${pathId}`} xmlSpace="preserve">
              {Array(copies).fill(copy).join("")}
            </textPath>
          </text>
        )}
      </svg>
    </div>
  );
}

export default RopeMarquee;

```

## Demo

```tsx
"use client";

import React from "react";
import { RopeMarquee } from "../mellow/rope-marquee";

export default function RopeMarqueeDemo() {
  return (
    <div className="flex h-full w-full flex-col items-center justify-center gap-4 py-8">
      <RopeMarquee
        text="Hand-set type on a real rope — grab it, yank it, let it swing"
        className="w-full"
        height={200}
      />
      <p className="font-mono text-[0.625rem] tracking-[0.24em] text-[rgba(var(--ink-rgb),0.35)] uppercase">
        Drag the line anywhere · space plucks it
      </p>
    </div>
  );
}

```
