# Kinetic Wall (`kinetic-wall`)

> A grid of miniature analog clocks whose hands choreograph to spell words — inspired by kinetic clock installations.

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

## AI prompt

Add a KineticWall component from the mellow library. It renders a grid of tiny analog clock faces whose hands smoothly rotate to form letterforms, cycling through a words array. Key props: words (string[]), transitionDuration (ms), holdDuration (ms), clockSize (px), gap (px).

## Install

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

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `words` | `string[]` | `["HELLO", "WORLD"]` | Array of words to cycle through. |
| `transitionDuration` | `number` | `5000` | Duration of the movement phase in milliseconds. |
| `holdDuration` | `number` | `2000` | Duration to hold the formed word in milliseconds. |
| `clockSize` | `number` | `36` | Diameter of each clock face in pixels. |
| `gap` | `number` | `4` | Gap between clocks in pixels. |
| `handColor` | `string` | — | Color of the clock hands. Defaults to themed ink color. |
| `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 { useEffect, useRef, useState, useCallback } from "react";

function useInkRgb(): string {
  const [rgb, setRgb] = useState<string>("244, 241, 236");
  useEffect(() => {
    const read = () => {
      const v = getComputedStyle(document.documentElement)
        .getPropertyValue("--ink-rgb")
        .trim();
      if (v) setRgb(v);
    };
    read();
    const obs = new MutationObserver(read);
    obs.observe(document.documentElement, {
      attributes: true,
      attributeFilter: ["class", "style", "data-theme"],
    });
    return () => obs.disconnect();
  }, []);
  return rgb;
}

export interface KineticWallProps {
  words?: string[];
  transitionDuration?: number;
  holdDuration?: number;
  clockSize?: number;
  gap?: number;
  handColor?: string;
  style?: React.CSSProperties;
  className?: string;
}

const ANG_U = 0;
const ANG_R = 90;
const ANG_D = 180;
const ANG_L = 270;
const ANG_REST = 225;

const CHAR_COLS = 3;
const CHAR_ROWS = 5;

type Edge = [number, number, number, number];
const ve = (r: number, c: number): Edge => [r, c, r + 1, c];
const he = (r: number, c: number): Edge => [r, c, r, c + 1];

function edgesToAngles(edges: Edge[]): [number, number][] {
  const cells: Map<string, number[]> = new Map();
  for (const [r1, c1, r2, c2] of edges) {
    const k1 = `${r1},${c1}`;
    const k2 = `${r2},${c2}`;
    if (!cells.has(k1)) cells.set(k1, []);
    if (!cells.has(k2)) cells.set(k2, []);
    if (r2 > r1) {
      cells.get(k1)!.push(ANG_D);
      cells.get(k2)!.push(ANG_U);
    } else if (r2 < r1) {
      cells.get(k1)!.push(ANG_U);
      cells.get(k2)!.push(ANG_D);
    } else if (c2 > c1) {
      cells.get(k1)!.push(ANG_R);
      cells.get(k2)!.push(ANG_L);
    } else {
      cells.get(k1)!.push(ANG_L);
      cells.get(k2)!.push(ANG_R);
    }
  }
  const grid: [number, number][] = [];
  for (let r = 0; r < CHAR_ROWS; r++) {
    for (let c = 0; c < CHAR_COLS; c++) {
      const dirs = cells.get(`${r},${c}`) || [];
      if (dirs.length >= 2) grid.push([dirs[0], dirs[1]]);
      else if (dirs.length === 1) grid.push([dirs[0], ANG_REST]);
      else grid.push([ANG_REST, ANG_REST]);
    }
  }
  return grid;
}

const FONT: Record<string, [number, number][]> = {
  A: edgesToAngles([
    he(0, 0), he(0, 1),
    ve(0, 0), ve(1, 0), ve(2, 0), ve(3, 0),
    ve(0, 2), ve(1, 2), ve(2, 2), ve(3, 2),
    he(2, 0), he(2, 1),
  ]),
  B: edgesToAngles([
    he(0, 0), he(0, 1),
    ve(0, 0), ve(1, 0), ve(2, 0), ve(3, 0),
    ve(0, 2), ve(1, 2), ve(2, 2), ve(3, 2),
    he(2, 0), he(2, 1),
    he(4, 0), he(4, 1),
  ]),
  C: edgesToAngles([
    he(0, 0), he(0, 1),
    ve(0, 0), ve(1, 0), ve(2, 0), ve(3, 0),
    he(4, 0), he(4, 1),
  ]),
  D: edgesToAngles([
    he(0, 0), he(0, 1),
    ve(0, 0), ve(1, 0), ve(2, 0), ve(3, 0),
    ve(0, 2), ve(1, 2), ve(2, 2), ve(3, 2),
    he(4, 0), he(4, 1),
  ]),
  E: edgesToAngles([
    he(0, 0), he(0, 1),
    ve(0, 0), ve(1, 0), ve(2, 0), ve(3, 0),
    he(2, 0), he(2, 1),
    he(4, 0), he(4, 1),
  ]),
  F: edgesToAngles([
    he(0, 0), he(0, 1),
    ve(0, 0), ve(1, 0), ve(2, 0), ve(3, 0),
    he(2, 0), he(2, 1),
  ]),
  G: edgesToAngles([
    he(0, 0), he(0, 1),
    ve(0, 0), ve(1, 0), ve(2, 0), ve(3, 0),
    he(4, 0), he(4, 1),
    ve(2, 2), ve(3, 2),
    he(2, 1),
  ]),
  H: edgesToAngles([
    ve(0, 0), ve(1, 0), ve(2, 0), ve(3, 0),
    ve(0, 2), ve(1, 2), ve(2, 2), ve(3, 2),
    he(2, 0), he(2, 1),
  ]),
  I: edgesToAngles([
    he(0, 0), he(0, 1),
    ve(0, 1), ve(1, 1), ve(2, 1), ve(3, 1),
    he(4, 0), he(4, 1),
  ]),
  J: edgesToAngles([
    ve(0, 2), ve(1, 2), ve(2, 2), ve(3, 2),
    he(4, 0), he(4, 1),
    ve(3, 0),
  ]),
  K: edgesToAngles([
    ve(0, 0), ve(1, 0), ve(2, 0), ve(3, 0),
    he(2, 0), he(2, 1),
    ve(0, 2), ve(1, 2),
    ve(2, 2), ve(3, 2),
  ]),
  L: edgesToAngles([
    ve(0, 0), ve(1, 0), ve(2, 0), ve(3, 0),
    he(4, 0), he(4, 1),
  ]),
  M: edgesToAngles([
    ve(0, 0), ve(1, 0), ve(2, 0), ve(3, 0),
    ve(0, 2), ve(1, 2), ve(2, 2), ve(3, 2),
    he(0, 0), he(0, 1),
    ve(0, 1), ve(1, 1),
  ]),
  N: edgesToAngles([
    ve(0, 0), ve(1, 0), ve(2, 0), ve(3, 0),
    ve(0, 2), ve(1, 2), ve(2, 2), ve(3, 2),
    he(0, 0), he(0, 1),
  ]),
  O: edgesToAngles([
    he(0, 0), he(0, 1),
    ve(0, 0), ve(1, 0), ve(2, 0), ve(3, 0),
    ve(0, 2), ve(1, 2), ve(2, 2), ve(3, 2),
    he(4, 0), he(4, 1),
  ]),
  P: edgesToAngles([
    he(0, 0), he(0, 1),
    ve(0, 0), ve(1, 0), ve(2, 0), ve(3, 0),
    ve(0, 2), ve(1, 2),
    he(2, 0), he(2, 1),
  ]),
  Q: edgesToAngles([
    he(0, 0), he(0, 1),
    ve(0, 0), ve(1, 0), ve(2, 0), ve(3, 0),
    ve(0, 2), ve(1, 2), ve(2, 2),
    he(4, 0), he(4, 1),
    ve(3, 2),
  ]),
  R: edgesToAngles([
    he(0, 0), he(0, 1),
    ve(0, 0), ve(1, 0), ve(2, 0), ve(3, 0),
    ve(0, 2), ve(1, 2),
    he(2, 0), he(2, 1),
    ve(2, 2), ve(3, 2),
  ]),
  S: edgesToAngles([
    he(0, 0), he(0, 1),
    ve(0, 0), ve(1, 0),
    he(2, 0), he(2, 1),
    ve(2, 2), ve(3, 2),
    he(4, 0), he(4, 1),
  ]),
  T: edgesToAngles([
    he(0, 0), he(0, 1),
    ve(0, 1), ve(1, 1), ve(2, 1), ve(3, 1),
  ]),
  U: edgesToAngles([
    ve(0, 0), ve(1, 0), ve(2, 0), ve(3, 0),
    ve(0, 2), ve(1, 2), ve(2, 2), ve(3, 2),
    he(4, 0), he(4, 1),
  ]),
  V: edgesToAngles([
    ve(0, 0), ve(1, 0), ve(2, 0),
    ve(0, 2), ve(1, 2), ve(2, 2),
    ve(3, 1),
    he(3, 0),
    he(3, 1),
  ]),
  W: edgesToAngles([
    ve(0, 0), ve(1, 0), ve(2, 0), ve(3, 0),
    ve(0, 2), ve(1, 2), ve(2, 2), ve(3, 2),
    he(4, 0), he(4, 1),
    ve(2, 1), ve(3, 1),
  ]),
  X: edgesToAngles([
    ve(0, 0), ve(1, 0),
    ve(0, 2), ve(1, 2),
    he(2, 0), he(2, 1),
    ve(2, 0), ve(3, 0),
    ve(2, 2), ve(3, 2),
  ]),
  Y: edgesToAngles([
    ve(0, 0), ve(1, 0),
    ve(0, 2), ve(1, 2),
    he(2, 0), he(2, 1),
    ve(2, 1), ve(3, 1),
  ]),
  Z: edgesToAngles([
    he(0, 0), he(0, 1),
    ve(0, 2), ve(1, 2),
    he(2, 0), he(2, 1),
    ve(2, 0), ve(3, 0),
    he(4, 0), he(4, 1),
  ]),
  "0": edgesToAngles([
    he(0, 0), he(0, 1),
    ve(0, 0), ve(1, 0), ve(2, 0), ve(3, 0),
    ve(0, 2), ve(1, 2), ve(2, 2), ve(3, 2),
    he(4, 0), he(4, 1),
  ]),
  "1": edgesToAngles([
    ve(0, 1), ve(1, 1), ve(2, 1), ve(3, 1),
    he(0, 0),
  ]),
  "2": edgesToAngles([
    he(0, 0), he(0, 1),
    ve(0, 2), ve(1, 2),
    he(2, 0), he(2, 1),
    ve(2, 0), ve(3, 0),
    he(4, 0), he(4, 1),
  ]),
  "3": edgesToAngles([
    he(0, 0), he(0, 1),
    ve(0, 2), ve(1, 2), ve(2, 2), ve(3, 2),
    he(2, 0), he(2, 1),
    he(4, 0), he(4, 1),
  ]),
  "4": edgesToAngles([
    ve(0, 0), ve(1, 0),
    ve(0, 2), ve(1, 2), ve(2, 2), ve(3, 2),
    he(2, 0), he(2, 1),
  ]),
  "5": edgesToAngles([
    he(0, 0), he(0, 1),
    ve(0, 0), ve(1, 0),
    he(2, 0), he(2, 1),
    ve(2, 2), ve(3, 2),
    he(4, 0), he(4, 1),
  ]),
  "6": edgesToAngles([
    he(0, 0), he(0, 1),
    ve(0, 0), ve(1, 0), ve(2, 0), ve(3, 0),
    he(2, 0), he(2, 1),
    ve(2, 2), ve(3, 2),
    he(4, 0), he(4, 1),
  ]),
  "7": edgesToAngles([
    he(0, 0), he(0, 1),
    ve(0, 2), ve(1, 2), ve(2, 2), ve(3, 2),
  ]),
  "8": edgesToAngles([
    he(0, 0), he(0, 1),
    ve(0, 0), ve(1, 0), ve(2, 0), ve(3, 0),
    ve(0, 2), ve(1, 2), ve(2, 2), ve(3, 2),
    he(2, 0), he(2, 1),
    he(4, 0), he(4, 1),
  ]),
  "9": edgesToAngles([
    he(0, 0), he(0, 1),
    ve(0, 0), ve(1, 0),
    ve(0, 2), ve(1, 2), ve(2, 2), ve(3, 2),
    he(2, 0), he(2, 1),
    he(4, 0), he(4, 1),
  ]),
  " ": Array.from({ length: CHAR_COLS * CHAR_ROWS }, (): [number, number] => [ANG_REST, ANG_REST]),
};

function wordToGrid(
  word: string
): { targets: [number, number][]; cols: number; rows: number } {
  const chars = word.toUpperCase().split("");
  const gapCols = 1;
  const totalCols =
    chars.length * CHAR_COLS + (chars.length - 1) * gapCols;
  const totalRows = CHAR_ROWS;
  const targets: [number, number][] = Array.from(
    { length: totalCols * totalRows },
    (): [number, number] => [ANG_REST, ANG_REST]
  );

  let colOffset = 0;
  for (const ch of chars) {
    const glyph = FONT[ch] || FONT[" "];
    for (let r = 0; r < CHAR_ROWS; r++) {
      for (let c = 0; c < CHAR_COLS; c++) {
        const srcIdx = r * CHAR_COLS + c;
        const dstIdx = r * totalCols + (colOffset + c);
        targets[dstIdx] = glyph[srcIdx];
      }
    }
    colOffset += CHAR_COLS + gapCols;
  }
  return { targets, cols: totalCols, rows: totalRows };
}

function lerpAngle(from: number, to: number, t: number): number {
  let diff = ((to - from + 540) % 360) - 180;
  return from + diff * t;
}

function easeInOutCubic(t: number): number {
  return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
}

export function KineticWall({
  words = ["HELLO", "WORLD"],
  transitionDuration = 5000,
  holdDuration = 2000,
  clockSize = 36,
  gap = 4,
  handColor,
  style,
  className,
}: KineticWallProps) {
  const inkRgb = useInkRgb();
  const resolvedHandColor = handColor ?? `rgba(${inkRgb}, 0.92)`;
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const wrapperRef = useRef<HTMLDivElement>(null);

  const wordIndexRef = useRef(0);
  const currentAnglesRef = useRef<[number, number][]>([]);
  const targetAnglesRef = useRef<[number, number][]>([]);
  const gridInfoRef = useRef({ cols: 0, rows: 0 });
  const animStartRef = useRef(0);
  const phaseRef = useRef<"hold" | "transition">("hold");
  const prevAnglesRef = useRef<[number, number][]>([]);

  const longestWord = words.reduce(
    (a, b) => (b.length > a.length ? b : a),
    ""
  );
  const { cols: maxCols } = wordToGrid(longestWord);

  const [containerWidth, setContainerWidth] = useState(0);

  useEffect(() => {
    const wrapper = wrapperRef.current;
    if (!wrapper) return;
    const ro = new ResizeObserver((entries) => {
      for (const entry of entries) {
        setContainerWidth(entry.contentRect.width);
      }
    });
    ro.observe(wrapper);
    return () => ro.disconnect();
  }, []);

  const idealWidth = maxCols * (clockSize + gap) - gap;
  const effectiveClockSize =
    containerWidth > 0 && idealWidth > containerWidth
      ? Math.floor((containerWidth + gap) / maxCols - gap)
      : clockSize;
  const clampedClockSize = Math.max(effectiveClockSize, 8);

  const canvasW = maxCols * (clampedClockSize + gap) - gap;
  const canvasH = CHAR_ROWS * (clampedClockSize + gap) - gap;

  const initWord = useCallback(() => {
    const { targets, cols, rows } = wordToGrid(words[0] || "");
    const padded = padToWidth(targets, cols, rows, maxCols);
    currentAnglesRef.current = padded.map(([a, b]) => [a, b]);
    targetAnglesRef.current = padded;
    prevAnglesRef.current = padded.map(([a, b]) => [a, b]);
    gridInfoRef.current = { cols: maxCols, rows };
    wordIndexRef.current = 0;
    phaseRef.current = "hold";
    animStartRef.current = performance.now();
  }, [words, maxCols]);

  useEffect(initWord, [initWord]);

  useEffect(() => {
    const reduced = window.matchMedia(
      "(prefers-reduced-motion: reduce)"
    ).matches;

    if (!canvasRef.current) return;
    const canvas: HTMLCanvasElement = canvasRef.current;
    const ctxOrNull = canvas.getContext("2d");
    if (!ctxOrNull) return;
    const ctx: CanvasRenderingContext2D = ctxOrNull;

    let rafId: number;

    function resize() {
      const dpr = Math.min(window.devicePixelRatio, 2);
      canvas.width = canvasW * dpr;
      canvas.height = canvasH * dpr;
      canvas.style.width = `${canvasW}px`;
      canvas.style.height = `${canvasH}px`;
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    }
    resize();

    const ro = new ResizeObserver(resize);
    ro.observe(canvas);

    function draw(now: number) {
      const { cols, rows } = gridInfoRef.current;
      if (cols === 0) {
        rafId = requestAnimationFrame(draw);
        return;
      }

      if (phaseRef.current === "transition" && !reduced) {
        const elapsed = now - animStartRef.current;
        const totalCells = cols * rows;
        const maxStagger = 400 + 200;

        for (let i = 0; i < totalCells; i++) {
          const prev = prevAnglesRef.current[i] || [ANG_REST, ANG_REST];
          const target = targetAnglesRef.current[i] || [ANG_REST, ANG_REST];

          const col = i % cols;
          const row = Math.floor(i / cols);
          const staggerDelay =
            (col / cols) * 400 + (row / rows) * 200;
          const localElapsed = Math.max(0, elapsed - staggerDelay);
          const rawT = Math.min(1, localElapsed / transitionDuration);
          const t = rawT;

          currentAnglesRef.current[i] = [
            lerpAngle(prev[0], target[0], t),
            lerpAngle(prev[1], target[1], t),
          ];
        }

        if (elapsed > transitionDuration + maxStagger) {
          currentAnglesRef.current = targetAnglesRef.current.map(
            ([a, b]) => [a, b]
          );
          phaseRef.current = "hold";
          animStartRef.current = now;
        }
      } else if (phaseRef.current === "hold" && !reduced) {
        const elapsed = now - animStartRef.current;
        if (elapsed > holdDuration && words.length > 1) {
          wordIndexRef.current = (wordIndexRef.current + 1) % words.length;
          const nextWord = words[wordIndexRef.current];
          const { targets, cols: wCols, rows: wRows } = wordToGrid(nextWord);
          const padded = padToWidth(targets, wCols, wRows, cols);

          prevAnglesRef.current = currentAnglesRef.current.map(([a, b]) => [a, b]);
          targetAnglesRef.current = padded;
          animStartRef.current = now;
          phaseRef.current = "transition";
        }
      } else if (reduced) {
        currentAnglesRef.current = targetAnglesRef.current.map(
          ([a, b]) => [a, b]
        );
      }

      ctx.clearRect(0, 0, canvasW, canvasH);
      const step = clampedClockSize + gap;
      const radius = clampedClockSize / 2;
      const handLen = radius * 0.82;

      for (let i = 0; i < cols * rows; i++) {
        const col = i % cols;
        const row = Math.floor(i / cols);
        const cx = col * step + radius;
        const cy = row * step + radius;
        const angles = currentAnglesRef.current[i] || [ANG_REST, ANG_REST];

        ctx.beginPath();
        ctx.arc(cx, cy, radius - 1, 0, Math.PI * 2);
        ctx.strokeStyle = `rgba(${inkRgb}, 0.06)`;
        ctx.lineWidth = 0.5;
        ctx.stroke();

        ctx.beginPath();
        ctx.arc(cx, cy, 1.5, 0, Math.PI * 2);
        ctx.fillStyle = `rgba(${inkRgb}, 0.15)`;
        ctx.fill();

        const ha = ((angles[0] - 90) * Math.PI) / 180;
        ctx.beginPath();
        ctx.moveTo(cx, cy);
        ctx.lineTo(cx + Math.cos(ha) * handLen, cy + Math.sin(ha) * handLen);
        ctx.strokeStyle = resolvedHandColor;
        ctx.lineWidth = 1.8;
        ctx.lineCap = "round";
        ctx.stroke();

        const ma = ((angles[1] - 90) * Math.PI) / 180;
        ctx.beginPath();
        ctx.moveTo(cx, cy);
        ctx.lineTo(cx + Math.cos(ma) * handLen, cy + Math.sin(ma) * handLen);
        ctx.strokeStyle = resolvedHandColor;
        ctx.lineWidth = 1.8;
        ctx.lineCap = "round";
        ctx.stroke();
      }

      rafId = requestAnimationFrame(draw);
    }

    rafId = requestAnimationFrame(draw);
    return () => {
      cancelAnimationFrame(rafId);
      ro.disconnect();
    };
  }, [canvasW, canvasH, clampedClockSize, gap, resolvedHandColor, inkRgb, transitionDuration, holdDuration, words]);

  return (
    <div
      ref={wrapperRef}
      className={["inline-flex items-center justify-center w-full max-w-full", className].filter(Boolean).join(" ")}
      style={style}
    >
      <canvas
        ref={canvasRef}
        className="block"
        aria-label={words.join(", ")}
      />
    </div>
  );
}

function padToWidth(
  targets: [number, number][],
  cols: number,
  rows: number,
  targetCols: number
): [number, number][] {
  if (cols >= targetCols) return targets;
  const padLeft = Math.floor((targetCols - cols) / 2);
  const out: [number, number][] = Array.from(
    { length: targetCols * rows },
    (): [number, number] => [ANG_REST, ANG_REST]
  );
  for (let r = 0; r < rows; r++) {
    for (let c = 0; c < cols; c++) {
      out[r * targetCols + padLeft + c] = targets[r * cols + c];
    }
  }
  return out;
}

export default KineticWall;

```

## Demo

```tsx
"use client";

import React from "react";
import { KineticWall } from "../mellow/kinetic-wall";

export default function KineticWallDemo() {
  return (
    <div className="flex flex-col items-center gap-6 py-8 px-4 w-full">
      <KineticWall
        words={["MELLOW", "DESIGN", "MOTION"]}
        transitionDuration={8000}
        holdDuration={10}
        clockSize={32}
        gap={0}
      />
      <p className="text-[rgba(var(--ink-rgb),0.3)] text-xs [font-family:var(--font-mono)] tracking-[0.12em] uppercase">
        Clock hands align to form letterforms
      </p>
    </div>
  );
}

```
