Skip to main content
Back to discovery

Live preview recorded from Mellow UI; component and demo published by Mellow UI. Preview: platform recorded · rights authorized.

Kinetic Wall

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

MSource LandMellow UI

Why it stands out

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

Prompt

You are given a task to integrate a React component into your codebase.
Please verify your project has the following setup:
- shadcn/ui project structure
- Tailwind CSS v4.0
- TypeScript
- motion/react v11

If any of these are missing, provide instructions on how to setup project via shadcn CLI, install Tailwind or TypeScript.

Determine the default path for components and styles. Mellow components live in `components/mellow/`. If that folder does not exist yet, create it.

Copy-paste these files:

File location: components/kinetic-wall-demo.tsx

File content: "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>
  );
}



File location: components/mellow/kinetic-wall.tsx

File content: "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;



Usage notes:
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).

Discovery vocabulary

Related by governed terms

Continue comparing

More from Mellow UI