# Hanging Photo Mobile with Balanced Wire Arms and Prints That Turn in the Air

> Photo mobile with prints on balanced wire arms: they sway and turn on their threads, and the whole thing tips when you pull one. Free React and HTML code.

A shadcn/ui block from the [@weinc](https://we.inc/components?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/weinc/hanging-photo-mobile-with-balanced-wire-arms-and-prints-that-turn-in-the-air).

- Page, with a live preview: https://shadcn.exchange/r/weinc/hanging-photo-mobile-with-balanced-wire-arms-and-prints-that-turn-in-the-air
- Screenshot: https://shadcn.exchange/shot/weinc/hanging-photo-mobile-with-balanced-wire-arms-and-prints-that-turn-in-the-air/light.png
- Registry item: https://we.inc/r/hanging-photo-mobile-with-balanced-wire-arms-and-prints-that-turn-in-the-air.json
- Listed: 2026-10-28, source last changed 2026-10-28

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/weinc/hanging-photo-mobile-with-balanced-wire-arms-and-prints-that-turn-in-the-air.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `framer-motion`

## Files

### `components/we-inc/hanging-photo-mobile-with-balanced-wire-arms-and-prints-that-turn-in-the-air.tsx`

```tsx
import { useCallback, useEffect, useMemo, useRef } from "react";
import type { PointerEvent as ReactPointerEvent } from "react";
import { useReducedMotion } from "framer-motion";

/** The prints, from the heaviest (hung highest, on the long arm) to the lightest. */
const prints = [
  { title: "Spot, signal red", src: "https://images.pexels.com/photos/14598236/pexels-photo-14598236.jpeg?auto=compress&cs=tinysrgb&w=600", alt: "Woman in dark sunglasses and a black top in front of a pale spotlight circle on a red wall", focus: "50% 35%", tone: "#b3202a", w: 186, h: 246, rest: -0.4 },
  { title: "Half sun", src: "https://images.pexels.com/photos/7516560/pexels-photo-7516560.jpeg?auto=compress&cs=tinysrgb&w=600", alt: "Woman in a red dress standing against a yellow wall with a large red half circle", focus: "50% 40%", tone: "#e0a21a", w: 150, h: 200, rest: 0.34 },
  { title: "Blue on blue", src: "https://images.pexels.com/photos/30755172/pexels-photo-30755172.jpeg?auto=compress&cs=tinysrgb&w=600", alt: "Woman in pale blue clothes crouching against a deep blue backdrop", focus: "50% 45%", tone: "#1f4fa3", w: 132, h: 176, rest: -0.26 },
  { title: "Wide brim", src: "https://images.pexels.com/photos/35024256/pexels-photo-35024256.jpeg?auto=compress&cs=tinysrgb&w=600", alt: "Person in a pale yellow suit and a wide black hat against a dark red backdrop", focus: "50% 35%", tone: "#6e0f14", w: 118, h: 156, rest: 0.46 },
];

// Design pixels for a 900 wide scene, drawn large and scaled down to fit so it stays sharp.
const W = 900;
const H = 780;
const CEILING = { x: 375, y: -8 };
const PULL = 96; // how far a print can be pulled from where it hangs
const GRAVITY = 0.3; // pixels per step squared, at 120 steps a second
const DAMPING = 0.9986;
const SHADOW = { x: 44, y: 56 };
const EYELET = 13; // how far below a print's top edge its thread ties on
const THICK = 4; // card thickness
const ARMS = [[225, 168], [126, 156], [93, 108]]; // left and right length of each wire arm, top arm first
const PIVOT = 0.3; // weight of a wire's hanging loop
const END = 0.3; // weight of a wire's end loop
const TIE = 0.2; // weight of the knot at a print's eyelet

type Pt = { x: number; y: number; px: number; py: number; w: number };
type Rod = { l: number; p: number; r: number };
type Card = { t: number; m: number; hx: number; hy: number };
type Model = { pts: Pt[]; links: [number, number, number][]; rods: Rod[]; cards: Card[]; threads: [number, number][] };

const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));

/**
 * Three wire arms, each hung from the one above. Arm lengths and print weights are chosen so
 * that weight times arm length matches on both sides of every pivot, which is what makes a
 * real mobile hang level.
 */
function buildModel(): Model {
  const pts: Pt[] = [];
  const links: [number, number, number][] = [];
  const rods: Rod[] = [];
  const cards: Card[] = [];
  const threads: [number, number][] = [];
  const add = (x: number, y: number, mass: number) => pts.push({ x, y, px: x, py: y, w: mass === 0 ? 0 : 1 / mass }) - 1;
  const join = (a: number, b: number) => links.push([a, b, Math.hypot(pts[a].x - pts[b].x, pts[a].y - pts[b].y)]);
  const rod = (from: number, thread: number, armL: number, armR: number, rise: number) => {
    const p = add(pts[from].x, pts[from].y + thread, PIVOT);
    const l = add(pts[p].x - armL, pts[p].y + rise, END);
    const r = add(pts[p].x + armR, pts[p].y + rise, END);
    join(from, p);
    join(p, l);
    join(p, r);
    join(l, r);
    threads.push([from, p]);
    rods.push({ l, p, r });
    return { l, r };
  };
  const card = (from: number, thread: number, index: number, mass: number) => {
    const t = add(pts[from].x, pts[from].y + thread, TIE);
    const m = add(pts[t].x, pts[t].y + prints[index].h * 0.5, mass);
    join(from, t);
    join(t, m);
    threads.push([from, t]);
    cards[index] = { t, m, hx: pts[m].x, hy: pts[m].y };
  };
  // work the weights out from the lightest print upward
  const m4 = 1;
  const m3 = ((END + TIE + m4) * ARMS[2][1]) / ARMS[2][0] - END - TIE;
  const massC = PIVOT + 2 * END + 2 * TIE + m3 + m4;
  const m2 = ((END + massC) * ARMS[1][1]) / ARMS[1][0] - END - TIE;
  const massB = PIVOT + 2 * END + TIE + m2 + massC;
  const m1 = ((END + massB) * ARMS[0][1]) / ARMS[0][0] - END - TIE;
  const pin = add(CEILING.x, CEILING.y, 0);
  const a = rod(pin, 99, ARMS[0][0], ARMS[0][1], 33);
  card(a.l, 84, 0, m1);
  const b = rod(a.r, 69, ARMS[1][0], ARMS[1][1], 24);
  card(b.l, 159, 1, m2);
  const c = rod(b.r, 48, ARMS[2][0], ARMS[2][1], 20);
  card(c.l, 186, 2, m3);
  card(c.r, 57, 3, m4);
  return { pts, links, rods, cards, threads };
}

export default function HangingPhotoMobile() {
  const reduce = useReducedMotion();
  const model = useMemo(buildModel, []);
  const rootRef = useRef<HTMLDivElement>(null);
  const sceneRef = useRef<HTMLDivElement>(null);
  const cardRefs = useRef<(HTMLDivElement | null)[]>([]);
  const slabRefs = useRef<(HTMLDivElement | null)[]>([]);
  const shadeRefs = useRef<(HTMLDivElement | null)[]>([]);
  const sheenRefs = useRef<(HTMLDivElement | null)[]>([]);
  const shadowRefs = useRef<(HTMLDivElement | null)[]>([]);
  const wireRefs = useRef<(SVGPathElement | null)[]>([]);
  const threadRefs = useRef<(SVGPathElement | null)[]>([]);
  const sim = useRef({
    scale: 1,
    drag: -1,
    tx: 0,
    ty: 0,
    ox: 0,
    oy: 0,
    lastTouch: 0,
    time: 0,
    gust: 1,
    yaw: prints.map((p) => p.rest),
    spin: prints.map(() => 0),
  });

  const breeze = useCallback(
    (strength: number) => {
      const s = sim.current;
      s.gust = -s.gust;
      model.cards.forEach((c, i) => {
        model.pts[c.m].px -= s.gust * strength * (1.05 + 0.33 * i);
        s.spin[i] += s.gust * strength * 0.0006 * (i % 2 ? -1 : 1);
      });
      s.lastTouch = performance.now();
    },
    [model],
  );

  useEffect(() => {
    const root = rootRef.current;
    const scene = sceneRef.current;
    if (!root || !scene) return;
    const s = sim.current;
    const { pts, links, rods, cards, threads } = model;
    let raf = 0;
    let running = false;
    let visible = true;
    let last = 0;
    let acc = 0;

    const fit = () => {
      s.scale = Math.min(1, root.clientWidth / W);
      scene.style.transform = `translateX(-50%) scale(${s.scale})`;
      root.style.height = `${Math.round(Math.max(H * s.scale, 420))}px`;
    };

    const stepOnce = () => {
      s.time += 1 / 120;
      for (let i = 1; i < pts.length; i++) {
        const p = pts[i];
        const vx = clamp((p.x - p.px) * DAMPING, -24, 24);
        const vy = clamp((p.y - p.py) * DAMPING, -24, 24);
        p.px = p.x;
        p.py = p.y;
        p.x += vx;
        p.y += vy + GRAVITY;
        if (p.y < 8) p.y = 8; // nothing swings up through the ceiling
      }
      for (let it = 0; it < 70; it++) {
        if (s.drag >= 0) {
          const g = pts[cards[s.drag].m];
          g.x += (s.tx - g.x) * 0.3;
          g.y += (s.ty - g.y) * 0.3;
        }
        for (let i = 0; i < links.length; i++) {
          const a = pts[links[i][0]];
          const b = pts[links[i][1]];
          const dx = b.x - a.x;
          const dy = b.y - a.y;
          const d = Math.hypot(dx, dy) || 0.0001;
          const sum = a.w + b.w;
          const k = (d - links[i][2]) / d;
          a.x += dx * k * (a.w / sum);
          a.y += dy * k * (a.w / sum);
          b.x -= dx * k * (b.w / sum);
          b.y -= dy * k * (b.w / sum);
        }
      }
      // each print also turns on its thread: the twist in the thread winds it back,
      // moving through the air turns it, and the room is never perfectly still
      for (let i = 0; i < cards.length; i++) {
        const m = pts[cards[i].m];
        const vx = m.x - m.px;
        s.spin[i] += (prints[i].rest - s.yaw[i]) * 0.00008 - s.spin[i] * 0.004 + vx * 0.0003 * (i % 2 ? -1 : 1) + Math.sin(s.time * (0.5 + i * 0.13) + i * 1.9) * 0.000016;
        s.spin[i] = clamp(s.spin[i], -0.06, 0.06);
        s.yaw[i] += s.spin[i];
      }
    };

    const draw = () => {
      for (let i = 0; i < cards.length; i++) {
        const t = pts[cards[i].t];
        const m = pts[cards[i].m];
        const p = prints[i];
        const tilt = (Math.atan2(-(m.x - t.x), m.y - t.y) * 180) / Math.PI;
        const yaw = s.yaw[i];
        const x = t.x - p.w / 2;
        const y = t.y - EYELET;
        const el = cardRefs.current[i];
        if (el) el.style.transform = `translate3d(${x.toFixed(2)}px, ${y.toFixed(2)}px, 0) rotate(${tilt.toFixed(2)}deg)`;
        const slab = slabRefs.current[i];
        if (slab) slab.style.transform = `rotateY(${((yaw * 180) / Math.PI).toFixed(2)}deg)`;
        const facing = Math.cos(yaw);
        const shade = shadeRefs.current[i];
        if (shade) shade.style.opacity = (0.5 * (1 - Math.abs(facing)) + (Math.sin(yaw) > 0 ? 0.07 : 0)).toFixed(3);
        const sheen = sheenRefs.current[i];
        if (sheen) {
          const off = yaw + 0.3;
          sheen.style.opacity = (0.4 * Math.exp(-(off * off) / 0.05)).toFixed(3);
          sheen.style.transform = `translateX(${(off * 160).toFixed(1)}%)`;
        }
        const sh = shadowRefs.current[i];
        if (sh) sh.style.transform = `translate3d(${(x + SHADOW.x).toFixed(2)}px, ${(y + SHADOW.y).toFixed(2)}px, 0) rotate(${tilt.toFixed(2)}deg) scaleX(${Math.max(0.04, Math.abs(facing)).toFixed(3)})`;
      }
      let wires = "";
      let loops = "";
      for (const r of rods) {
        const l = pts[r.l];
        const p = pts[r.p];
        const rr = pts[r.r];
        const cx = 2 * p.x - (l.x + rr.x) / 2;
        const cy = 2 * p.y - (l.y + rr.y) / 2;
        wires += `M${l.x.toFixed(1)} ${l.y.toFixed(1)}Q${cx.toFixed(1)} ${cy.toFixed(1)} ${rr.x.toFixed(1)} ${rr.y.toFixed(1)}`;
        for (const e of [l, rr]) loops += `M${(e.x + 4.6).toFixed(1)} ${(e.y + 4.4).toFixed(1)}a4.6 4.6 0 1 1 -9.2 0a4.6 4.6 0 1 1 9.2 0`;
        loops += `M${(p.x + 4.4).toFixed(1)} ${(p.y - 4.4).toFixed(1)}a4.4 4.4 0 1 1 -8.8 0a4.4 4.4 0 1 1 8.8 0`;
      }
      let lines = "";
      for (const [a, b] of threads) lines += `M${pts[a].x.toFixed(1)} ${pts[a].y.toFixed(1)}L${pts[b].x.toFixed(1)} ${pts[b].y.toFixed(1)}`;
      wireRefs.current.forEach((el, i) => el && el.setAttribute("d", i < 3 ? wires : wires + loops));
      threadRefs.current.forEach((el) => el && el.setAttribute("d", lines));
    };

    const frame = (now: number) => {
      raf = requestAnimationFrame(frame);
      acc += Math.min(0.05, (now - last) / 1000);
      last = now;
      let n = 0;
      while (acc >= 1 / 120 && n < 6) {
        stepOnce();
        acc -= 1 / 120;
        n++;
      }
      if (s.drag < 0 && now - s.lastTouch > 6500) breeze(1.3);
      draw();
    };

    const start = () => {
      if (running || reduce || !visible || document.hidden) return;
      running = true;
      last = performance.now();
      acc = 0;
      raf = requestAnimationFrame(frame);
    };
    const stop = () => {
      running = false;
      cancelAnimationFrame(raf);
    };

    fit();
    if (!reduce) {
      // start it already moving, as if someone has just walked past
      breeze(1.7);
      s.lastTouch = performance.now();
    }
    draw();
    const ro = new ResizeObserver(fit);
    ro.observe(root);
    const io = new IntersectionObserver(([entry]) => {
      visible = entry.isIntersecting;
      if (visible) start();
      else stop();
    });
    io.observe(root);
    const vis = () => (document.hidden ? stop() : start());
    document.addEventListener("visibilitychange", vis);
    start();
    return () => {
      stop();
      ro.disconnect();
      io.disconnect();
      document.removeEventListener("visibilitychange", vis);
    };
  }, [reduce, model, breeze]);

  const aim = (e: ReactPointerEvent) => {
    const scene = sceneRef.current;
    if (!scene) return;
    const s = sim.current;
    const r = scene.getBoundingClientRect();
    s.tx = (e.clientX - r.left) / s.scale + s.ox;
    s.ty = (e.clientY - r.top) / s.scale + s.oy;
    if (s.drag >= 0) {
      const home = model.cards[s.drag];
      const dx = s.tx - home.hx;
      const dy = s.ty - home.hy;
      const d = Math.hypot(dx, dy);
      if (d > PULL) {
        s.tx = home.hx + (dx / d) * PULL;
        s.ty = home.hy + (dy / d) * PULL;
      }
    }
  };
  const grab = (i: number) => (e: ReactPointerEvent<HTMLDivElement>) => {
    if (reduce) return;
    const s = sim.current;
    const m = model.pts[model.cards[i].m];
    s.ox = 0;
    s.oy = 0;
    aim(e);
    s.ox = m.x - s.tx;
    s.oy = m.y - s.ty;
    s.drag = i;
    aim(e);
    e.currentTarget.setPointerCapture(e.pointerId);
  };
  const release = () => {
    sim.current.drag = -1;
    sim.current.lastTouch = performance.now();
  };

  return (
    <div
      ref={rootRef}
      className="relative h-[780px] w-full select-none overflow-hidden bg-[#efeeea]"
      style={{ backgroundImage: "radial-gradient(110% 90% at 30% 8%, #ffffff 0%, #f1f0ec 48%, #dddbd4 100%)" }}
    >
      <div ref={sceneRef} className="absolute left-1/2 top-0 origin-top" style={{ width: W, height: H, transform: "translateX(-50%)", perspective: 1900, perspectiveOrigin: "50% 45%" }}>
        {/* shadows on the wall behind */}
        <svg className="pointer-events-none absolute left-0 top-0 overflow-visible blur-[6px]" width={W} height={H} style={{ transform: `translate(${SHADOW.x}px, ${SHADOW.y}px)` }} aria-hidden="true">
          <path ref={(el) => { wireRefs.current[0] = el; }} fill="none" stroke="#1d1a14" strokeOpacity="0.2" strokeWidth="5" strokeLinecap="round" />
          <path ref={(el) => { threadRefs.current[0] = el; }} fill="none" stroke="#1d1a14" strokeOpacity="0.12" strokeWidth="2" />
        </svg>
        {prints.map((p, i) => (
          <div
            key={p.title}
            ref={(el) => { shadowRefs.current[i] = el; }}
            className="pointer-events-none absolute left-0 top-0 rounded-[3px] bg-[#1d1a14]/25 blur-[18px]"
            style={{ width: p.w, height: p.h, transformOrigin: `50% ${EYELET}px` }}
          />
        ))}

        {/* threads */}
        <svg className="pointer-events-none absolute left-0 top-0 overflow-visible" width={W} height={H} aria-hidden="true">
          <path ref={(el) => { threadRefs.current[1] = el; }} fill="none" stroke="#5c5850" strokeWidth="1.3" />
        </svg>

        {/* the prints */}
        {prints.map((p, i) => (
          <div
            key={p.title}
            ref={(el) => { cardRefs.current[i] = el; }}
            onPointerDown={grab(i)}
            onPointerMove={(e) => sim.current.drag === i && aim(e)}
            onPointerUp={release}
            onPointerCancel={release}
            className="absolute left-0 top-0 cursor-grab touch-none active:cursor-grabbing"
            style={{ width: p.w, height: p.h, transformOrigin: `50% ${EYELET}px`, transformStyle: "preserve-3d" }}
          >
            <div ref={(el) => { slabRefs.current[i] = el; }} className="absolute inset-0" style={{ transformStyle: "preserve-3d", transform: `rotateY(${p.rest}rad)` }}>
              {/* front: the photograph with a white border */}
              <div className="absolute inset-0 overflow-hidden rounded-[2px] bg-[#fbfaf6]" style={{ transform: `translateZ(${THICK / 2}px)`, backfaceVisibility: "hidden", boxShadow: "inset 0 0 0 0.5px rgba(40,36,28,0.28)" }}>
                <div className="absolute inset-x-[9px] bottom-[9px] top-[27px] overflow-hidden" style={{ backgroundColor: p.tone }}>
                  <img src={p.src} alt={p.alt} draggable={false} width={p.w - 18} height={p.h - 36} className="h-full w-full object-cover" style={{ objectPosition: p.focus }} />
                  <div className="absolute inset-0" style={{ boxShadow: "inset 0 0 0 0.5px rgba(0,0,0,0.25)" }} />
                </div>
                <span className="absolute left-[10px] top-[9px] font-mono text-[7.5px] uppercase leading-none tracking-[0.18em] text-[#8a8473]">No. {String(i + 1).padStart(2, "0")}</span>
                <div ref={(el) => { shadeRefs.current[i] = el; }} className="pointer-events-none absolute inset-0 bg-[#14110b]" style={{ opacity: 0 }} />
                <div ref={(el) => { sheenRefs.current[i] = el; }} className="pointer-events-none absolute inset-y-0 left-[20%] w-[60%]" style={{ opacity: 0, background: "linear-gradient(100deg, rgba(255,255,255,0) 0%, rgba(255,255,255,0.85) 50%, rgba(255,255,255,0) 100%)" }} />
              </div>
              {/* back: plain card with the print's number and title */}
              <div className="absolute inset-0 flex flex-col justify-end rounded-[2px] bg-[#e9e5da] p-[14px]" style={{ transform: `rotateY(180deg) translateZ(${THICK / 2}px)`, backfaceVisibility: "hidden", boxShadow: "inset 0 0 0 0.5px rgba(40,36,28,0.3), inset 0 0 30px rgba(60,50,30,0.16)" }}>
                <span className="font-mono text-[10px] uppercase tracking-[0.2em] text-[#6b6455]">No. {String(i + 1).padStart(2, "0")}</span>
                <span className="mt-[4px] font-serif text-[16px] italic leading-tight text-[#2c2820]">{p.title}</span>
              </div>
              {/* the two long edges, so the card has thickness when it turns side on */}
              <div className="absolute top-0 h-full bg-[#cfcabd]" style={{ width: THICK, left: -THICK / 2, transform: "rotateY(90deg)" }} />
              <div className="absolute top-0 h-full bg-[#f4f1e8]" style={{ width: THICK, right: -THICK / 2, transform: "rotateY(90deg)" }} />
              {/* brass eyelet the thread ties through */}
              <div className="absolute h-[13px] w-[13px] rounded-full" style={{ left: "calc(50% - 6.5px)", top: EYELET - 6.5, transform: `translateZ(${THICK / 2 + 0.4}px)`, background: "radial-gradient(circle at 35% 30%, #fff6cf 0%, #d9b458 45%, #8a6519 100%)", boxShadow: "0 0.5px 1px rgba(0,0,0,0.5)", backfaceVisibility: "hidden" }}>
                <div className="absolute inset-[3.8px] rounded-full bg-[#2a251b]" />
              </div>
            </div>
          </div>
        ))}

        {/* wire arms, in front of the threads */}
        <svg className="pointer-events-none absolute left-0 top-0 overflow-visible" width={W} height={H} aria-hidden="true">
          <path ref={(el) => { wireRefs.current[3] = el; }} fill="none" stroke="#17150f" strokeWidth="3.8" strokeLinecap="round" />
          <path ref={(el) => { wireRefs.current[1] = el; }} fill="none" stroke="#8d8573" strokeWidth="1.2" strokeLinecap="round" transform="translate(-0.7 -1.1)" />
          <path ref={(el) => { wireRefs.current[2] = el; }} fill="none" stroke="#ffffff" strokeOpacity="0.5" strokeWidth="0.6" strokeLinecap="round" transform="translate(-0.8 -1.3)" />
        </svg>
      </div>

      <p className="pointer-events-none absolute bottom-6 left-6 font-mono text-[10px] uppercase leading-[1.7] tracking-[0.18em] text-[#77736a]">
        <span className="font-semibold text-[#1d1a14]">Four prints / three wires</span>
        <br />
        Pull one and the rest answer
      </p>
      <button
        type="button"
        onClick={() => breeze(3)}
        aria-label="Send a breeze through the mobile"
        className="absolute bottom-5 right-5 flex h-11 w-11 items-center justify-center rounded-full border border-[#1d1a14]/20 bg-white/70 text-[#1d1a14] transition-colors hover:bg-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1d1a14] focus-visible:ring-offset-2 focus-visible:ring-offset-[#efeeea]"
      >
        <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
          <path d="M2.5 7.5h8.2a2.3 2.3 0 1 0-2.2-3" />
          <path d="M2.5 11h12.3a2.4 2.4 0 1 1-2.3 3.2" />
          <path d="M2.5 14.5h5.5" />
        </svg>
      </button>
    </div>
  );
}
```
