Device Mockup ASCII Screen

Device mockup

A phone-frame device mockup whose screen is a live ASCII/scanline raster; dragging the handle skews the frame while the raster independently resamples its glyph rows to the same tilt.

Install
npx shadcn add https://design.helpmarq.com/r/device-mockup-ascii-screen.json
Source
registry/core/device-mockup-ascii-screen/component.tsx
"use client";

import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent } from "react";

// ---------------------------------------------------------------------------
// DeviceMockupAsciiScreen — a phone-frame device mockup whose screen content
// is a live ASCII/scanline raster (a fake app UI rendered as monospace glyph
// density plus a slow scanline sweep), not a static screenshot. Dragging the
// handle tilts the FRAME via a real CSS skew() transform, while the screen's
// own canvas draw pass independently RESAMPLES its glyph raster to the same
// skew — every row's glyphs are shifted horizontally by an amount
// proportional to that row's distance from center and the current skew
// angle, recomputed every frame the tilt changes — so the content genuinely
// re-renders into the tilt rather than being a flat texture wrapped in the
// bezel's own CSS transform. This stays a 2D skew, deliberately: no
// perspective/projection engine, no three. Distinct from image-crop-mat
// (four mat boards defining a crop window over a static photo, no tilt, no
// raster) and minimap-pantograph (an IK linkage between two scroll
// viewports, no device chrome and no glyph rendering at all).
// ---------------------------------------------------------------------------

export interface DeviceMockupAsciiScreenProps {
  className?: string;
}

const MAX_SKEW = 14; // deg
const DRAG_RANGE = 120; // px of drag to reach MAX_SKEW
const SETTLE_MS = 420;
const CELL = 8;
const SCAN_PERIOD_MS = 3200;

interface Tokens {
  fg: string;
  bg: string;
  border: string;
  accent: string;
}

function readTokens(): Tokens {
  const cs = getComputedStyle(document.documentElement);
  const get = (name: string, fallback: string) => cs.getPropertyValue(name).trim() || fallback;
  return {
    fg: get("--foreground", "#ededed"),
    bg: get("--background", "#0a0a0a"),
    border: get("--border", "#2e2e2e"),
    accent: get("--accent", "#006bff"),
  };
}

// fake UI layout: a row-density map (0..1) standing in for a header, a
// stat block and a few paragraph lines — enough visual structure for the
// shear to read against.
function densityAt(gx: number, gy: number, cols: number, rows: number): number {
  const rowFrac = gy / rows;
  if (rowFrac < 0.08) return 0.95; // header bar
  if (rowFrac < 0.28) {
    // stat tiles: three blocks with gaps
    const colFrac = gx / cols;
    const inBlock = colFrac % 0.34 < 0.28;
    return inBlock ? 0.6 : 0;
  }
  if (rowFrac > 0.92) return 0.15; // footer hairline
  // paragraph lines: alternating density bands with ragged right edge
  const lineIdx = Math.floor((rowFrac - 0.32) * 26);
  if (lineIdx < 0) return 0;
  const lineOn = lineIdx % 3 !== 2;
  if (!lineOn) return 0;
  const raggedEnd = 0.35 + 0.55 * ((Math.sin(lineIdx * 1.7) + 1) / 2);
  const colFrac = gx / cols;
  return colFrac < raggedEnd ? 0.5 : 0;
}

function easeOutCubic(t: number) {
  const c = Math.min(1, Math.max(0, t));
  return 1 - Math.pow(1 - c, 3);
}

export function DeviceMockupAsciiScreen({ className = "" }: DeviceMockupAsciiScreenProps) {
  const frameRef = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const tokensRef = useRef<Tokens>(
    typeof document === "undefined"
      ? { fg: "#ededed", bg: "#0a0a0a", border: "#2e2e2e", accent: "#006bff" }
      : readTokens()
  );
  const reducedRef = useRef(false);
  const rafRef = useRef(0);

  const skewRef = useRef({ x: 0, y: 0 });
  const targetRef = useRef({ x: 0, y: 0 });
  const settleRef = useRef({ from: { x: 0, y: 0 }, start: 0, active: false });
  const draggingRef = useRef(false);
  const startRef = useRef({ x: 0, y: 0, skewX: 0, skewY: 0 });

  const [, forceTick] = useState(0);

  useEffect(() => {
    reducedRef.current = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    const mo = new MutationObserver(() => {
      tokensRef.current = readTokens();
    });
    mo.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "style"] });
    return () => mo.disconnect();
  }, []);

  const applyFrameTransform = () => {
    const frame = frameRef.current;
    if (!frame) return;
    frame.style.transform = `skew(${skewRef.current.x.toFixed(2)}deg, ${skewRef.current.y.toFixed(2)}deg)`;
  };

  const paint = (t: number) => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;
    const dpr = Math.min(window.devicePixelRatio || 1, 2);
    const cssW = canvas.clientWidth || 1;
    const cssH = canvas.clientHeight || 1;
    const targetW = Math.ceil(cssW * dpr);
    const targetH = Math.ceil(cssH * dpr);
    if (canvas.width !== targetW || canvas.height !== targetH) {
      canvas.width = targetW;
      canvas.height = targetH;
    }
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    const { fg, bg, accent } = tokensRef.current;
    ctx.fillStyle = bg;
    ctx.fillRect(0, 0, cssW, cssH);

    const cols = Math.max(1, Math.ceil(cssW / CELL));
    const rows = Math.max(1, Math.ceil(cssH / CELL));
    const cellW = cssW / cols;
    const cellH = cssH / rows;
    // shear factor in px-per-row, derived from the live skewX angle: the
    // raster resamples to the SAME tilt the frame's CSS transform applies
    const shearPxPerRow = Math.tan((skewRef.current.x * Math.PI) / 180) * cellH * -1;
    const centerRow = rows / 2;

    ctx.font = `${Math.max(6, Math.round(cellH * 0.92))}px "GeistMono", ui-monospace, monospace`;
    ctx.textAlign = "left";
    ctx.textBaseline = "top";

    const scanProgress = reducedRef.current ? 0.5 : (t % SCAN_PERIOD_MS) / SCAN_PERIOD_MS;
    const scanRow = scanProgress * rows;

    for (let gy = 0; gy < rows; gy++) {
      const rowShear = shearPxPerRow * (gy - centerRow);
      const nearScan = Math.abs(gy - scanRow) < 1.4;
      for (let gx = 0; gx < cols; gx++) {
        const d = densityAt(gx, gy, cols, rows);
        if (d <= 0) continue;
        const ch = d > 0.85 ? "#" : d > 0.55 ? "=" : d > 0.3 ? "-" : ".";
        ctx.fillStyle = nearScan ? accent : fg;
        ctx.globalAlpha = nearScan ? 1 : 0.65 + d * 0.3;
        ctx.fillText(ch, gx * cellW + rowShear, gy * cellH);
      }
    }
    ctx.globalAlpha = 1;
  };

  useEffect(() => {
    let mounted = true;
    const loop = (t: number) => {
      if (!mounted) return;
      // settle animation: eases skew back toward target (usually 0 on release)
      const s = settleRef.current;
      if (s.active) {
        const elapsed = performance.now() - s.start;
        const p = Math.min(1, elapsed / SETTLE_MS);
        const e = easeOutCubic(p);
        skewRef.current = {
          x: s.from.x + (targetRef.current.x - s.from.x) * e,
          y: s.from.y + (targetRef.current.y - s.from.y) * e,
        };
        applyFrameTransform();
        if (p >= 1) s.active = false;
      }
      paint(t);
      const needsMotion = !reducedRef.current || s.active || draggingRef.current;
      if (needsMotion) rafRef.current = requestAnimationFrame(loop);
    };
    rafRef.current = requestAnimationFrame(loop);
    return () => {
      mounted = false;
      cancelAnimationFrame(rafRef.current);
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  const setSkew = (x: number, y: number) => {
    skewRef.current = {
      x: Math.max(-MAX_SKEW, Math.min(MAX_SKEW, x)),
      y: Math.max(-MAX_SKEW, Math.min(MAX_SKEW, y)),
    };
    targetRef.current = { ...skewRef.current };
    applyFrameTransform();
    forceTick((n) => n + 1);
  };

  const onPointerDown = (e: ReactPointerEvent) => {
    draggingRef.current = true;
    settleRef.current.active = false;
    startRef.current = { x: e.clientX, y: e.clientY, skewX: skewRef.current.x, skewY: skewRef.current.y };
    (e.target as Element).setPointerCapture(e.pointerId);
    if (rafRef.current === 0) rafRef.current = requestAnimationFrame(paint as unknown as FrameRequestCallback);
  };
  const onPointerMove = (e: ReactPointerEvent) => {
    if (!draggingRef.current) return;
    const dx = e.clientX - startRef.current.x;
    const dy = e.clientY - startRef.current.y;
    const nx = startRef.current.skewX + (dx / DRAG_RANGE) * MAX_SKEW;
    const ny = startRef.current.skewY + (dy / DRAG_RANGE) * MAX_SKEW;
    skewRef.current = {
      x: Math.max(-MAX_SKEW, Math.min(MAX_SKEW, nx)),
      y: Math.max(-MAX_SKEW, Math.min(MAX_SKEW, ny)),
    };
    applyFrameTransform();
  };
  const settleToZero = () => {
    settleRef.current = { from: { ...skewRef.current }, start: performance.now(), active: true };
    targetRef.current = { x: 0, y: 0 };
    if (rafRef.current === 0) {
      const loop = (t: number) => {
        const s = settleRef.current;
        if (s.active) {
          const elapsed = performance.now() - s.start;
          const p = Math.min(1, elapsed / SETTLE_MS);
          const e = easeOutCubic(p);
          skewRef.current = {
            x: s.from.x + (targetRef.current.x - s.from.x) * e,
            y: s.from.y + (targetRef.current.y - s.from.y) * e,
          };
          applyFrameTransform();
          if (p >= 1) s.active = false;
        }
        paint(t);
        if (s.active) rafRef.current = requestAnimationFrame(loop);
        else rafRef.current = 0;
      };
      rafRef.current = requestAnimationFrame(loop);
    }
  };
  const onPointerUp = (e: ReactPointerEvent) => {
    if (!draggingRef.current) return;
    draggingRef.current = false;
    try {
      (e.target as Element).releasePointerCapture(e.pointerId);
    } catch {
      // synthetic pointer during autoplay — nothing to release
    }
    if (reducedRef.current) {
      setSkew(0, 0);
    } else {
      settleToZero();
    }
  };

  const onKeyDown = (e: ReactKeyboardEvent) => {
    const step = 3;
    if (e.key === "ArrowLeft") {
      e.preventDefault();
      setSkew(skewRef.current.x - step, skewRef.current.y);
    } else if (e.key === "ArrowRight") {
      e.preventDefault();
      setSkew(skewRef.current.x + step, skewRef.current.y);
    } else if (e.key === "ArrowUp") {
      e.preventDefault();
      setSkew(skewRef.current.x, skewRef.current.y - step);
    } else if (e.key === "ArrowDown") {
      e.preventDefault();
      setSkew(skewRef.current.x, skewRef.current.y + step);
    } else if (e.key === "Enter" || e.key === " " || e.key === "Escape") {
      e.preventDefault();
      if (reducedRef.current) setSkew(0, 0);
      else settleToZero();
    }
  };

  return (
    <div className={`flex flex-col items-center gap-4 ${className}`}>
      <div
        ref={frameRef}
        data-device-frame
        className="relative rounded-[28px] border-[6px] border-border bg-background p-2 shadow-sm"
        style={{ width: 220, height: 440, transformOrigin: "center" }}
      >
        <span
          aria-hidden
          className="absolute left-1/2 top-2 h-1.5 w-14 -translate-x-1/2 rounded-full bg-border"
        />
        <canvas ref={canvasRef} aria-hidden className="block h-full w-full rounded-[18px]" />
      </div>

      <div
        role="button"
        tabIndex={0}
        aria-label="Drag or use arrow keys to tilt the device"
        onPointerDown={onPointerDown}
        onPointerMove={onPointerMove}
        onPointerUp={onPointerUp}
        onPointerCancel={onPointerUp}
        onKeyDown={onKeyDown}
        className="cursor-grab select-none touch-none rounded-full border border-border bg-surface px-4 py-1.5 font-mono text-[11px] tracking-widest text-muted transition-colors duration-150 hover:border-foreground/25 hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent active:cursor-grabbing"
      >
        DRAG TO TILT
      </div>
    </div>
  );
}
Use when

a device mockup whose screen content is a live, re-sampling raster reacting to a 2D drag tilt — pick image-crop-mat instead for defining a crop window over a static photo with no device chrome, or minimap-pantograph for a scroll-position linkage with no screen rendering at all.

Build spec

Build <DeviceMockupAsciiScreen className?>: a phone-shaped frame (rounded-[28px], thick border, a small pill notch) sized 220x440, its screen a <canvas> rendering a fake app UI (header bar, a row of stat blocks, ragged paragraph lines) as literal ASCII glyphs (# = - . by density) at an 8px cell pitch, plus a slow scanline sweep (one bright accent-colored row cycling top to bottom every ~3.2s, paused at its mid-position under prefers-reduced-motion). THE MECHANIC: a real drag handle below the frame (role=button, tabIndex 0, aria-label 'Drag or use arrow keys to tilt the device') applies a CSS `skew(Xdeg, Ydeg)` transform DIRECTLY to the frame element as the pointer moves (drag range +-120px maps to +-14deg on each axis, arrow keys nudge 3deg per press as a keyboard-accessible equivalent) — that transform tilts the bezel, the notch, everything. Independently, the canvas draw pass reads the CURRENT live skewX angle every frame and resamples its OWN glyph raster to match: each text row's horizontal draw position is offset by `tan(skewX) * cellHeight * (row - centerRow)`, recomputed on every paint while dragging, so the screen's ASCII content visibly shears in sync with the frame's tilt through its own procedural redraw rather than being a flat texture merely wrapped inside the frame's CSS transform (drop it and the content would never re-align with a tilted bezel). On release the skew eases back to 0 over ~420ms (eased, one rAF loop) unless reduced motion is on, in which case it snaps directly to 0. This stays a deliberate 2D skew — no perspective/projection math, no three, no WebGL. Colors are token-only: canvas ink var(--foreground), scanline var(--accent), background var(--background)/var(--border) for the bezel, read via getComputedStyle and re-read on a MutationObserver watching the root's class/style attributes. The drag handle is the only interactive control — a real role=button with a visible hover and focus-visible state distinct from rest — everything else (frame, canvas) is aria-hidden decoration. Zero dependencies.

Tags
device-mockupphoneasciicanvasdragscanline