Demos

Interactive Form

2D Beginner

A full HTML form rendered inside canvas — inputs, checkboxes, selects, sliders, and buttons all remain fully interactive, proving that typing, clicking, and tabbing work natively.

Source Code

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Interactive Form — HTML-in-Canvas</title>
    <style>
      * {
        box-sizing: border-box;
      }

      html {
        height: 100%;
      }

      body,
      :host {
        display: block;
        position: relative;
        margin: 0;
        width: 100%;
        min-height: min(78vh, 820px);
        height: 100%;
        background:
          radial-gradient(
            ellipse at top right,
            rgba(108, 65, 240, 0.15),
            transparent 55%
          ),
          radial-gradient(
            ellipse at bottom left,
            rgba(0, 229, 185, 0.12),
            transparent 60%
          ),
          #0a0a0f;
        color: #f0f0f0;
        font-family: system-ui, -apple-system, sans-serif;
        overflow: hidden;
      }

      /* The canvas fills the stage; the form is laid out by the
         layoutsubtree contract and drawn into the canvas pixels. */
      #canvas {
        position: absolute;
        inset: 0;
        width: 100%;
        height: 100%;
        display: block;
      }

      /*
       * The form is a real DOM element. Every input, select, slider,
       * checkbox, radio, and button is interactive — clicks, focus,
       * keyboard, autofill, and screen readers all work natively
       * because these are real elements, not painted approximations.
       *
       * Position is set explicitly here. Each frame, drawElementImage
       * returns a DOMMatrix that we apply back to #form-root so the
       * DOM elements stay aligned with the painted pixels.
       */
      #form-root {
        position: absolute;
        left: 0;
        top: 0;
        width: min(560px, calc(100vw - 4rem));
        padding: 1.75rem 2rem;
        border-radius: 18px;
        background: rgba(20, 20, 31, 0.78);
        border: 1px solid rgba(255, 255, 255, 0.08);
        backdrop-filter: blur(14px);
        box-shadow:
          0 20px 60px -20px rgba(0, 0, 0, 0.6),
          0 0 0 1px rgba(108, 65, 240, 0.08);
        font-family: system-ui, -apple-system, sans-serif;
        font-size: 0.875rem;
        color: #e0e0f0;
        transition: none;
        transform-origin: 0 0;
      }

      .form-title {
        font-size: 1rem;
        font-weight: 700;
        margin: 0 0 1.25rem;
        color: #f0f0f0;
        letter-spacing: -0.01em;
      }

      .form-grid {
        display: grid;
        grid-template-columns: 1fr 1fr;
        gap: 0.85rem 1rem;
      }

      .field {
        display: flex;
        flex-direction: column;
        gap: 0.3rem;
      }

      .field.full-width {
        grid-column: 1 / -1;
      }

      .field label {
        font-size: 0.7rem;
        font-weight: 600;
        color: #8080a0;
        text-transform: uppercase;
        letter-spacing: 0.05em;
      }

      .field input[type="text"],
      .field input[type="email"],
      .field textarea,
      .field select {
        background: #0d0d18;
        border: 1px solid #282840;
        border-radius: 6px;
        padding: 0.5rem 0.75rem;
        color: #e0e0f0;
        font-family: inherit;
        font-size: 0.85rem;
        outline: none;
        transition: border-color 0.15s, box-shadow 0.15s;
      }

      .field input[type="text"]:focus,
      .field input[type="email"]:focus,
      .field textarea:focus,
      .field select:focus {
        border-color: #6c41f0;
        box-shadow: 0 0 0 3px rgba(108, 65, 240, 0.18);
      }

      .field textarea {
        resize: vertical;
        min-height: 3.25rem;
        line-height: 1.45;
      }

      .field select {
        cursor: pointer;
        appearance: none;
        background-image:
          linear-gradient(45deg, transparent 50%, #6a6a80 50%),
          linear-gradient(135deg, #6a6a80 50%, transparent 50%);
        background-position:
          calc(100% - 14px) 50%,
          calc(100% - 9px) 50%;
        background-size: 5px 5px;
        background-repeat: no-repeat;
        padding-right: 2rem;
      }

      .inline-options {
        display: flex;
        gap: 0.85rem;
        flex-wrap: wrap;
      }

      .inline-options label {
        display: flex;
        align-items: center;
        gap: 0.35rem;
        font-size: 0.78rem;
        font-weight: 400;
        color: #c0c0d8;
        text-transform: none;
        letter-spacing: 0;
        cursor: pointer;
      }

      .inline-options input[type="checkbox"],
      .inline-options input[type="radio"] {
        accent-color: #6c41f0;
        width: 0.95rem;
        height: 0.95rem;
        cursor: pointer;
      }

      .slider-row {
        display: flex;
        align-items: center;
        gap: 0.6rem;
      }

      .slider-row input[type="range"] {
        flex: 1;
        accent-color: #6c41f0;
        height: 6px;
        cursor: pointer;
      }

      .slider-value {
        font-size: 0.78rem;
        color: #6c41f0;
        font-weight: 700;
        min-width: 2rem;
        text-align: right;
        font-family: "JetBrains Mono", "Fira Code", ui-monospace, monospace;
      }

      .form-actions {
        grid-column: 1 / -1;
        display: flex;
        gap: 0.6rem;
        justify-content: flex-end;
        padding-top: 0.25rem;
      }

      .btn {
        padding: 0.5rem 1.1rem;
        border-radius: 6px;
        border: 1px solid #282840;
        font-family: inherit;
        font-size: 0.78rem;
        font-weight: 600;
        cursor: pointer;
        transition: background 0.15s, border-color 0.15s;
      }

      .btn-secondary {
        background: transparent;
        color: #a0a0b8;
      }

      .btn-secondary:hover {
        background: rgba(255, 255, 255, 0.04);
        border-color: #3a3a58;
      }

      .btn-primary {
        background: #6c41f0;
        color: #fff;
        border-color: #6c41f0;
      }

      .btn-primary:hover {
        background: #7a52ff;
        border-color: #7a52ff;
      }

      .btn:focus-visible {
        outline: 2px solid #00e5b9;
        outline-offset: 2px;
      }

      /* Floating paint counter — top-right of stage */
      .paint-badge {
        position: absolute;
        top: 1.25rem;
        right: 1.25rem;
        z-index: 2;
        display: flex;
        flex-direction: column;
        align-items: flex-end;
        gap: 0.15rem;
        padding: 0.55rem 0.85rem;
        background: rgba(15, 15, 22, 0.78);
        border: 1px solid rgba(255, 255, 255, 0.08);
        border-radius: 10px;
        backdrop-filter: blur(10px);
        font-family: system-ui, -apple-system, sans-serif;
      }

      .paint-badge .label {
        font-size: 0.65rem;
        font-weight: 600;
        text-transform: uppercase;
        letter-spacing: 0.06em;
        color: #6a6a80;
      }

      .paint-badge .count {
        font-family: "JetBrains Mono", "Fira Code", ui-monospace, monospace;
        font-size: 1.1rem;
        font-weight: 700;
        color: #00e5b9;
        line-height: 1;
      }

      .paint-badge .hint {
        font-size: 0.62rem;
        color: #555570;
        margin-top: 0.1rem;
      }
    </style>
  </head>
  <body>
    <canvas id="canvas" layoutsubtree>
      <div id="form-root">
        <h1 class="form-title">Sign up</h1>
        <div class="form-grid">
          <div class="field">
            <label for="name">Name</label>
            <input type="text" id="name" placeholder="Jane Doe" autocomplete="off" />
          </div>

          <div class="field">
            <label for="email">Email</label>
            <input type="email" id="email" placeholder="jane@example.com" autocomplete="off" />
          </div>

          <div class="field full-width">
            <label for="message">Message</label>
            <textarea id="message" rows="2" placeholder="Type something..."></textarea>
          </div>

          <div class="field">
            <label for="role">Role</label>
            <select id="role">
              <option value="">Choose one</option>
              <option value="developer">Developer</option>
              <option value="designer">Designer</option>
              <option value="pm">Product Manager</option>
              <option value="other">Other</option>
            </select>
          </div>

          <div class="field">
            <label>Experience</label>
            <div class="slider-row">
              <input type="range" id="experience" min="0" max="10" value="5" />
              <span class="slider-value" id="exp-value">5</span>
            </div>
          </div>

          <div class="field full-width">
            <label>Interests</label>
            <div class="inline-options">
              <label><input type="checkbox" name="interest" value="graphics" /> Graphics</label>
              <label><input type="checkbox" name="interest" value="a11y" /> Accessibility</label>
              <label><input type="checkbox" name="interest" value="perf" /> Performance</label>
            </div>
          </div>

          <div class="field full-width">
            <label>Preferred API</label>
            <div class="inline-options">
              <label><input type="radio" name="api" value="2d" checked /> Canvas 2D</label>
              <label><input type="radio" name="api" value="webgl" /> WebGL</label>
              <label><input type="radio" name="api" value="webgpu" /> WebGPU</label>
            </div>
          </div>

          <div class="form-actions">
            <button type="button" class="btn btn-secondary" id="btn-reset">Reset</button>
            <button type="button" class="btn btn-primary" id="btn-submit">Submit</button>
          </div>
        </div>
      </div>
    </canvas>

    <div class="paint-badge">
      <div class="label">Paint events</div>
      <div class="count" id="paint-count">0</div>
      <div class="hint">Focus a text field — cursor blink fires onpaint</div>
    </div>

    <script>
      // Resolve the script's root: a ShadowRoot when mounted via shadow
      // DOM, or `document` when this file is served standalone.
      const root = window.__demoRoot ?? document;
      const $ = (id) => root.getElementById(id);

      const canvas = $("canvas");
      const ctx = canvas.getContext("2d");
      const formRoot = $("form-root");
      const paintCountEl = $("paint-count");

      let paintCount = 0;

      // ── Hand-drawn mindmap annotation around the form ────────
      // The form sits at the centre. The canvas's 2D context draws
      // sketched arrows, labels, and decorative scribbles around it
      // to make the demo feel like an annotated drawing on graph
      // paper. This is the "very canvassy" wrapper the user asked
      // for: a real DOM form positioned as the centerpiece of a
      // canvas illustration.
      const ANNOTATIONS = [
        {
          label: 'real <input> elements',
          targetField: 'name',
          side: 'left',
        },
        {
          label: 'native <textarea>',
          targetField: 'message',
          side: 'right',
        },
        {
          label: 'real <select>',
          targetField: 'role',
          side: 'left',
        },
        {
          label: 'live cursor blink → onpaint',
          targetField: 'email',
          side: 'right',
        },
        {
          label: 'native focus rings',
          targetField: 'experience',
          side: 'left',
        },
      ];

      // Random-but-stable jitter so the sketch lines look hand-drawn
      // without redrawing differently every frame.
      function jitterFor(seed) {
        const r = Math.sin(seed * 12.9898) * 43758.5453;
        return (r - Math.floor(r)) * 2 - 1;
      }

      function sketchLine(x1, y1, x2, y2, seed) {
        // Draw a slightly wobbly line by perturbing the midpoint and
        // rendering as a quadratic curve. Stable across frames so it
        // doesn't shimmer.
        const mx = (x1 + x2) / 2;
        const my = (y1 + y2) / 2;
        const dx = x2 - x1;
        const dy = y2 - y1;
        const len = Math.hypot(dx, dy);
        const nx = -dy / len;
        const ny = dx / len;
        const wob = jitterFor(seed) * Math.min(8, len * 0.04);
        ctx.beginPath();
        ctx.moveTo(x1, y1);
        ctx.quadraticCurveTo(mx + nx * wob, my + ny * wob, x2, y2);
        ctx.stroke();
      }

      function sketchArrowhead(x, y, angle) {
        const len = 10;
        ctx.beginPath();
        ctx.moveTo(x, y);
        ctx.lineTo(
          x - Math.cos(angle - 0.4) * len,
          y - Math.sin(angle - 0.4) * len,
        );
        ctx.moveTo(x, y);
        ctx.lineTo(
          x - Math.cos(angle + 0.4) * len,
          y - Math.sin(angle + 0.4) * len,
        );
        ctx.stroke();
      }

      function drawDotGrid(w, h) {
        ctx.fillStyle = 'rgba(108, 65, 240, 0.07)';
        const step = 28;
        for (let gx = step; gx < w; gx += step) {
          for (let gy = step; gy < h; gy += step) {
            ctx.beginPath();
            ctx.arc(gx, gy, 1.1, 0, Math.PI * 2);
            ctx.fill();
          }
        }
      }

      function drawDecorations(w, h, formRect) {
        // Soft glow circles in the corners
        const grad1 = ctx.createRadialGradient(
          w * 0.85,
          h * 0.15,
          0,
          w * 0.85,
          h * 0.15,
          Math.min(w, h) * 0.45,
        );
        grad1.addColorStop(0, 'rgba(108, 65, 240, 0.18)');
        grad1.addColorStop(1, 'transparent');
        ctx.fillStyle = grad1;
        ctx.fillRect(0, 0, w, h);

        const grad2 = ctx.createRadialGradient(
          w * 0.15,
          h * 0.85,
          0,
          w * 0.15,
          h * 0.85,
          Math.min(w, h) * 0.45,
        );
        grad2.addColorStop(0, 'rgba(0, 229, 185, 0.14)');
        grad2.addColorStop(1, 'transparent');
        ctx.fillStyle = grad2;
        ctx.fillRect(0, 0, w, h);

        // Sketchy spiral in the top-left
        ctx.strokeStyle = 'rgba(255, 255, 255, 0.08)';
        ctx.lineWidth = 1.2;
        ctx.beginPath();
        const sx = 90;
        const sy = 90;
        for (let t = 0; t < Math.PI * 4; t += 0.1) {
          const r = t * 4;
          const x = sx + Math.cos(t) * r;
          const y = sy + Math.sin(t) * r;
          if (t === 0) ctx.moveTo(x, y);
          else ctx.lineTo(x, y);
        }
        ctx.stroke();

        // Constellation of dots in the bottom-right
        ctx.fillStyle = 'rgba(0, 229, 185, 0.35)';
        const dots = [
          [w - 80, h - 60],
          [w - 130, h - 90],
          [w - 60, h - 110],
          [w - 100, h - 140],
          [w - 160, h - 130],
        ];
        for (const [dx, dy] of dots) {
          ctx.beginPath();
          ctx.arc(dx, dy, 2.2, 0, Math.PI * 2);
          ctx.fill();
        }
        ctx.strokeStyle = 'rgba(0, 229, 185, 0.18)';
        ctx.lineWidth = 1;
        ctx.beginPath();
        for (let i = 0; i < dots.length; i++) {
          const [x, y] = dots[i];
          if (i === 0) ctx.moveTo(x, y);
          else ctx.lineTo(x, y);
        }
        ctx.stroke();
      }

      function drawAnnotations(formX, formY, formW, formH) {
        ctx.font =
          '600 12px ui-monospace, "JetBrains Mono", "Cascadia Code", monospace';
        ctx.fillStyle = '#a78bfa';
        ctx.strokeStyle = 'rgba(167, 139, 250, 0.55)';
        ctx.lineWidth = 1.4;
        ctx.lineCap = 'round';

        ANNOTATIONS.forEach((ann, i) => {
          const target = root.getElementById(ann.targetField);
          if (!target) return;
          const fieldRect = target.getBoundingClientRect();
          const formRect = formRoot.getBoundingClientRect();
          // The form has a CSS transform applied; compute the
          // field's position in canvas coordinates by offsetting
          // from the rendered form's screen position.
          const fy = formY + (fieldRect.top - formRect.top) + fieldRect.height / 2;
          const onLeft = ann.side === 'left';
          const fx = onLeft ? formX + 12 : formX + formW - 12;

          // Arrow start point — out in the margin
          const labelX = onLeft ? formX - 110 : formX + formW + 110;
          const labelY = fy + jitterFor(i + 7) * 6;

          ctx.fillStyle = '#a78bfa';
          ctx.textAlign = onLeft ? 'right' : 'left';
          ctx.textBaseline = 'middle';
          ctx.fillText(ann.label, labelX, labelY);

          // Sketchy line from label edge → field
          const startX = onLeft
            ? labelX + 6
            : labelX - 6 - ctx.measureText(ann.label).width;
          // hmm: when textAlign is 'left', text grows right from labelX
          // when 'right', text grows left from labelX
          // so the right edge of label = labelX (for right-aligned) or labelX + width (for left-aligned)
          const labelEdge = onLeft
            ? labelX + 8
            : labelX - 8;

          ctx.strokeStyle = 'rgba(167, 139, 250, 0.55)';
          sketchLine(labelEdge, labelY, fx, fy, i + 1);

          // Arrowhead at field end
          const angle = Math.atan2(fy - labelY, fx - labelEdge);
          sketchArrowhead(fx, fy, angle);
        });
      }

      // Center the form once we know its measured size, then sync its
      // CSS transform to the painted matrix on every frame.
      canvas.onpaint = () => {
        // Bitmap is 1:1 with CSS pixels; see project memory
        // `project_layoutsubtree_bitmap_layout`. No DPR scaling.
        const cssW = canvas.width;
        const cssH = canvas.height;

        ctx.reset();

        // Background decorations and dot grid (drawn first, behind form)
        drawDotGrid(cssW, cssH);
        const rect = formRoot.getBoundingClientRect();
        const x = Math.max(160, (cssW - rect.width) / 2);
        const y = Math.max(40, (cssH - rect.height) / 2);
        drawDecorations(cssW, cssH, { x, y, w: rect.width, h: rect.height });

        // Annotations (sketched arrows + labels around the form)
        drawAnnotations(x, y, rect.width, rect.height);

        // Now paint the actual form on top
        const transform = ctx.drawElementImage(formRoot, x, y);
        if (transform) formRoot.style.transform = transform.toString();

        paintCount++;
        paintCountEl.textContent = paintCount;
      };

      // ResizeObserver: keep the bitmap 1:1 with CSS pixels. We do NOT
      // scale by devicePixelRatio because Chrome's layoutsubtree uses
      // canvas.width as the layout viewport for child elements — a
      // DPR-scaled bitmap breaks child layout on Retina.
      new ResizeObserver(([entry]) => {
        const { width, height } = entry.contentRect;
        canvas.width = Math.round(width);
        canvas.height = Math.round(height);
        canvas.requestPaint?.();
      }).observe(canvas);

      // Slider value mirror.
      const slider = $("experience");
      const expValue = $("exp-value");
      slider.addEventListener("input", () => {
        expValue.textContent = slider.value;
      });

      // Reset button.
      $("btn-reset").addEventListener("click", () => {
        $("name").value = "";
        $("email").value = "";
        $("message").value = "";
        $("role").selectedIndex = 0;
        slider.value = 5;
        expValue.textContent = "5";
        formRoot
          .querySelectorAll('input[type="checkbox"]')
          .forEach((cb) => {
            cb.checked = false;
          });
        const defaultRadio = formRoot.querySelector(
          'input[type="radio"][value="2d"]',
        );
        if (defaultRadio) defaultRadio.checked = true;
        canvas.requestPaint?.();
      });

      // Submit button — log the data, no actual submission.
      $("btn-submit").addEventListener("click", () => {
        const data = {
          name: $("name").value,
          email: $("email").value,
          message: $("message").value,
          role: $("role").value,
          experience: slider.value,
          interests: Array.from(
            formRoot.querySelectorAll('input[name="interest"]:checked'),
          ).map((cb) => cb.value),
          api: formRoot.querySelector('input[name="api"]:checked')?.value,
        };
        console.log("Form submitted:", data);
      });
    </script>
  </body>
</html>