# Lanyard Badge (`lanyard-badge`)

> A badge dangling from a lanyard with real rope physics — grab it, throw it, and it swings on a verlet-simulated strap until it settles.

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

## AI prompt

Add a LanyardBadge component from the mellow library — a conference badge hanging from a verlet-simulated lanyard that can be grabbed and thrown. Put the badge face in `children`, and tune `height`, `strapLength`, `cardWidth`, `cardHeight` and `gravity`.

## Install

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

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `children` | `React.ReactNode` | — | Badge face content — fills the card. |
| `height` | `number` | `430` | Container height in px. |
| `strapLength` | `number` | `185` | Strap rest length in px, anchor to clip. |
| `cardWidth` | `number` | `240` | Card width in px. |
| `cardHeight` | `number` | `150` | Card height in px. |
| `gravity` | `number` | `2600` | Downward pull in px/s² — higher swings snappier. |
| `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, useRef, useState } from "react";

export interface LanyardBadgeProps {
  /** Badge face content — fills the card. */
  children: React.ReactNode;
  /** Container height in px. */
  height?: number;
  /** Strap rest length in px, anchor to clip. */
  strapLength?: number;
  /** Card size in px. */
  cardWidth?: number;
  cardHeight?: number;
  /** Downward pull in px/s² — higher swings snappier. */
  gravity?: number;
  className?: string;
  style?: React.CSSProperties;
}

const SEGS = 12;
const DT = 1 / 120;
const CLIP_GAP = 14;

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

/**
 * A badge dangling from a lanyard with real rope physics — grab it, throw
 * it, and it swings on a verlet-simulated strap until it settles.
 */
export function LanyardBadge({
  children,
  height = 430,
  strapLength = 185,
  cardWidth = 240,
  cardHeight = 150,
  gravity = 2600,
  className,
  style,
}: LanyardBadgeProps) {
  const wrapRef = useRef<HTMLDivElement>(null);
  const cardRef = useRef<HTMLDivElement>(null);
  const bandRef = useRef<SVGPathElement>(null);
  const stitchRef = useRef<SVGPathElement>(null);
  const [reduced, setReduced] = useState(false);
  const [ready, setReady] = useState(false);
  const impulseRef = useRef<(vx: number) => void>(() => {});

  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);
  }, []);

  useEffect(() => {
    if (!wrapRef.current || !cardRef.current || !bandRef.current || !stitchRef.current) return;
    const wrap: HTMLDivElement = wrapRef.current;
    const cardEl: HTMLDivElement = cardRef.current;
    const band: SVGPathElement = bandRef.current;
    const stitch: SVGPathElement = stitchRef.current;

    let w = wrap.clientWidth || 600;
    const anchorY = 10;
    const seg = strapLength / SEGS;
    const attachDist = cardHeight / 2 + CLIP_GAP;

    const mk = (x: number, y: number): Pt => ({ x, y, px: x, py: y });
    const pts: Pt[] = Array.from({ length: SEGS + 1 }, (_, i) =>
      mk(w / 2, anchorY + seg * i)
    );
    const card = mk(w / 2, anchorY + strapLength + attachDist);

    const draw = () => {
      let d = `M ${pts[0].x.toFixed(1)} ${pts[0].y.toFixed(1)}`;
      for (let i = 1; i < SEGS; 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)}`;
      }
      d += ` L ${pts[SEGS].x.toFixed(1)} ${pts[SEGS].y.toFixed(1)}`;
      band.setAttribute("d", d);
      stitch.setAttribute("d", d);
      const ang =
        (Math.atan2(card.y - pts[SEGS].y, card.x - pts[SEGS].x) * 180) / Math.PI - 90;
      cardEl.style.transform = `translate(${(card.x - cardWidth / 2).toFixed(1)}px, ${(
        card.y - cardHeight / 2
      ).toFixed(1)}px) rotate(${ang.toFixed(2)}deg)`;
    };

    if (reduced) {
      // Static hang — straight strap, no simulation.
      const place = () => {
        w = wrap.clientWidth || 600;
        for (let i = 0; i <= SEGS; i++) {
          pts[i].x = w / 2;
          pts[i].y = anchorY + seg * i;
        }
        card.x = w / 2;
        card.y = anchorY + strapLength + attachDist;
        draw();
      };
      place();
      setReady(true);
      const ro = new ResizeObserver(place);
      ro.observe(wrap);
      return () => ro.disconnect();
    }

    // gentle push so it arrives swinging
    card.px = card.x - 2.2;

    let drag: { tx: number; ty: number; offX: number; offY: number } | null = null;
    let rect: DOMRect | null = null;
    let raf = 0;
    let running = false;
    let last = 0;
    let acc = 0;
    let idle = 0;

    const solve = (a: Pt, b: Pt, rest: number, wa: number, wb: number) => {
      const dx = b.x - a.x;
      const dy = b.y - a.y;
      const dist = Math.hypot(dx, dy) || 0.0001;
      const diff = (dist - rest) / dist;
      a.x += dx * diff * wa;
      a.y += dy * diff * wa;
      b.x -= dx * diff * wb;
      b.y -= dy * diff * wb;
    };

    const step = () => {
      const g = gravity * DT * DT;
      for (let i = 1; i <= SEGS; i++) {
        const p = pts[i];
        const vx = (p.x - p.px) * 0.992;
        const vy = (p.y - p.py) * 0.992;
        p.px = p.x;
        p.py = p.y;
        p.x += vx;
        p.y += vy + g;
      }
      {
        const vx = (card.x - card.px) * 0.995;
        const vy = (card.y - card.py) * 0.995;
        card.px = card.x;
        card.py = card.y;
        card.x += vx;
        card.y += vy + g;
      }
      for (let it = 0; it < 18; it++) {
        pts[0].x = w / 2;
        pts[0].y = anchorY;
        for (let i = 0; i < SEGS; i++) {
          solve(pts[i], pts[i + 1], seg, i === 0 ? 0 : 0.5, i === 0 ? 1 : 0.5);
        }
        solve(pts[SEGS], card, attachDist, 0.85, 0.15);
        if (drag) {
          card.x = drag.tx;
          card.y = drag.ty;
        }
      }
    };

    const loop = (now: number) => {
      acc += Math.min((now - last) / 1000, 1 / 30);
      last = now;
      while (acc >= DT) {
        step();
        acc -= DT;
      }
      draw();
      const speed =
        Math.abs(card.x - card.px) +
        Math.abs(card.y - card.py) +
        Math.abs(pts[SEGS >> 1].x - pts[SEGS >> 1].px);
      idle = speed < 0.03 && !drag ? idle + 1 : 0;
      if (idle > 150) {
        running = false;
        return;
      }
      raf = requestAnimationFrame(loop);
    };

    const wake = () => {
      if (running) return;
      running = true;
      idle = 0;
      acc = 0;
      last = performance.now();
      raf = requestAnimationFrame(loop);
    };

    const onDown = (e: PointerEvent) => {
      try {
        cardEl.setPointerCapture(e.pointerId);
      } catch {
        /* synthetic pointer — capture unavailable */
      }
      rect = wrap.getBoundingClientRect();
      drag = {
        tx: card.x,
        ty: card.y,
        offX: card.x - (e.clientX - rect.left),
        offY: card.y - (e.clientY - rect.top),
      };
      wake();
    };
    const onMove = (e: PointerEvent) => {
      if (!drag || !rect) return;
      drag.tx = Math.max(-60, Math.min(w + 60, e.clientX - rect.left + drag.offX));
      drag.ty = Math.max(0, Math.min(height + 80, e.clientY - rect.top + drag.offY));
    };
    const onUp = () => {
      drag = null;
    };

    cardEl.addEventListener("pointerdown", onDown);
    cardEl.addEventListener("pointermove", onMove);
    cardEl.addEventListener("pointerup", onUp);
    cardEl.addEventListener("pointercancel", onUp);

    impulseRef.current = (vx: number) => {
      card.px = card.x - vx;
      wake();
    };

    const ro = new ResizeObserver(() => {
      w = wrap.clientWidth || 600;
      wake();
    });
    ro.observe(wrap);

    draw();
    setReady(true);
    wake();

    return () => {
      running = false;
      cancelAnimationFrame(raf);
      ro.disconnect();
      cardEl.removeEventListener("pointerdown", onDown);
      cardEl.removeEventListener("pointermove", onMove);
      cardEl.removeEventListener("pointerup", onUp);
      cardEl.removeEventListener("pointercancel", onUp);
      impulseRef.current = () => {};
    };
  }, [reduced, height, strapLength, cardWidth, cardHeight, gravity]);

  return (
    <div
      ref={wrapRef}
      role="group"
      aria-label="Badge hanging on a lanyard — drag it to swing, or nudge with the arrow keys"
      tabIndex={0}
      onKeyDown={(e) => {
        if (e.key === "ArrowLeft") {
          e.preventDefault();
          impulseRef.current(-6);
        }
        if (e.key === "ArrowRight") {
          e.preventDefault();
          impulseRef.current(6);
        }
      }}
      className={[
        "relative w-full overflow-x-visible overflow-y-hidden outline-none focus-visible:ring-2 focus-visible:ring-[rgba(var(--ink-rgb),0.3)]",
        className,
      ]
        .filter(Boolean)
        .join(" ")}
      style={{ height, ...style }}
    >
      <svg aria-hidden="true" className="absolute inset-0 h-full w-full">
        {/* anchor pin */}
        <circle cx="50%" cy="10" r="4" style={{ fill: "rgba(var(--ink-rgb),0.5)" }} />
        <path
          ref={bandRef}
          fill="none"
          strokeLinecap="round"
          style={{ stroke: "rgba(var(--ink-rgb),0.8)", strokeWidth: 9 }}
        />
        <path
          ref={stitchRef}
          fill="none"
          strokeLinecap="round"
          strokeDasharray="5 6"
          style={{ stroke: "rgba(var(--background-rgb),0.65)", strokeWidth: 1.5 }}
        />
      </svg>
      <div
        ref={cardRef}
        className="absolute top-0 left-0 cursor-grab select-none will-change-transform active:cursor-grabbing"
        style={{
          width: cardWidth,
          height: cardHeight,
          touchAction: "none",
          opacity: ready ? 1 : 0,
        }}
      >
        {/* clip clamp bridging strap end to card */}
        <div
          aria-hidden="true"
          className="absolute left-1/2 w-9 -translate-x-1/2 rounded-t-[4px] border border-b-0 border-[var(--rule)] bg-[rgba(var(--ink-rgb),0.16)]"
          style={{ top: -CLIP_GAP, height: CLIP_GAP }}
        >
          <div className="absolute inset-x-2.5 top-[4px] h-[3px] rounded-full bg-[rgba(var(--background-rgb),0.8)]" />
        </div>
        <div className="relative h-full w-full overflow-hidden rounded-xl border border-[var(--rule)] bg-[var(--background)] shadow-[0_30px_60px_-24px_rgba(0,0,0,0.5)]">
          <div
            aria-hidden="true"
            className="pointer-events-none absolute inset-0 bg-[rgba(var(--ink-rgb),0.03)]"
          />
          {/* punch slot */}
          <div
            aria-hidden="true"
            className="absolute top-[7px] left-1/2 h-[5px] w-8 -translate-x-1/2 rounded-full border border-[var(--rule)] bg-[rgba(var(--ink-rgb),0.12)]"
          />
          {children}
        </div>
      </div>
    </div>
  );
}

export default LanyardBadge;

```

## Demo

```tsx
"use client";

import React from "react";
import { LanyardBadge } from "../mellow/lanyard-badge";

const BARS = [3, 1, 4, 2, 1, 5, 2, 3, 1, 2, 4, 1, 3, 2, 5, 1, 2, 3, 1, 4];

/** The badge face — exported so the Lab dangles the same card. */
export function LanyardBadgeFace() {
  return (
    <div className="flex h-full flex-col justify-between p-3 pt-4 sm:p-4 sm:pt-5">
      <div className="flex items-start justify-between">
        <span className="[font-family:var(--font-mono)] text-[0.5625rem] font-medium tracking-[0.18em] text-[rgba(var(--ink-rgb),0.45)] uppercase">
          Mellow Conf ’26
        </span>
        <span className="[font-family:var(--font-mono)] text-[0.5625rem] font-medium tracking-[0.18em] text-[rgba(var(--ink-rgb),0.45)] uppercase">
          Nº 018
        </span>
      </div>
      <div>
        <p className="[font-family:var(--font-serif)] text-2xl leading-none text-[var(--ink)] italic">
          Ada Lovelace
        </p>
        <p className="mt-1 [font-family:var(--font-mono)] text-[0.5625rem] font-medium tracking-[0.16em] text-[rgba(var(--ink-rgb),0.5)] uppercase">
          Speaker · Analytical Engines
        </p>
      </div>
      <div className="flex h-4 items-end gap-[3px]" aria-hidden="true">
        {BARS.map((b, i) => (
          <span
            key={i}
            className="h-full bg-[rgba(var(--ink-rgb),0.65)]"
            style={{ width: b }}
          />
        ))}
      </div>
    </div>
  );
}

export default function LanyardBadgeDemo() {
  return (
    <div className="flex w-full flex-col items-center gap-1.5 p-2 sm:gap-2 sm:p-4">
      <div className="w-full">
        <LanyardBadge>
          <LanyardBadgeFace />
        </LanyardBadge>
      </div>
      <p className="text-[0.8125rem] text-[rgba(var(--ink-rgb),0.35)]">
        Grab the badge and throw it
      </p>
    </div>
  );
}

```
