Demos

HTML Video Recording

2D Intermediate

Record animated HTML content as WebM video — type a message, watch it animate with CSS, hit record, and download the clip via canvas.captureStream() + MediaRecorder.

Source Code

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>HTML Video Recording — HTML-in-Canvas</title>
    <link rel="preconnect" href="https://fonts.googleapis.com" />
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
    <link
      href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;600;800&display=swap"
      rel="stylesheet"
    />
    <style>
      * {
        box-sizing: border-box;
      }

      html {
        height: 100%;
      }

      body,
      :host {
        display: block;
        margin: 0;
        min-height: 100%;
        background: #0a0a0f;
        color: #f0f0f0;
        font-family: system-ui, -apple-system, sans-serif;
        padding: 1.5rem;
        overflow-x: hidden;
      }

      .recorder {
        max-width: 760px;
        margin: 0 auto;
        display: flex;
        flex-direction: column;
        gap: 1rem;
      }

      /* ==========================================================
         Canvas preview wrapper
         ========================================================== */
      .preview {
        background: #14141f;
        border-radius: 12px;
        padding: 1rem;
        border: 1px solid #282840;
        position: relative;
      }

      canvas {
        width: 100%;
        aspect-ratio: 16 / 9;
        display: block;
        border-radius: 6px;
      }

      /* Recording indicator — red dot in corner */
      .rec-badge {
        position: absolute;
        top: 1.5rem;
        right: 1.5rem;
        display: flex;
        align-items: center;
        gap: 0.4rem;
        padding: 0.3rem 0.7rem;
        background: rgba(0, 0, 0, 0.6);
        border-radius: 100px;
        font-size: 0.7rem;
        font-weight: 700;
        color: #ff4444;
        text-transform: uppercase;
        letter-spacing: 0.06em;
        opacity: 0;
        pointer-events: none;
        transition: opacity 0.2s ease;
      }

      .rec-badge.active {
        opacity: 1;
      }

      .rec-dot {
        width: 8px;
        height: 8px;
        border-radius: 50%;
        background: #ff4444;
        animation: rec-pulse 1s ease-in-out infinite;
      }

      @keyframes rec-pulse {
        0%,
        100% {
          opacity: 1;
        }
        50% {
          opacity: 0.3;
        }
      }

      /* ==========================================================
         Animated scene — rendered inside canvas via layoutsubtree.
         Continuous CSS animations prove the canvas faithfully
         captures every frame of HTML animation to video.
         ========================================================== */
      #scene {
        --hue-offset: 0;

        width: 100%;
        height: 100%;
        background: linear-gradient(
          135deg,
          hsl(calc(260 + var(--hue-offset)), 70%, 25%),
          hsl(calc(170 + var(--hue-offset)), 80%, 35%)
        );
        font-family: "Montserrat", system-ui, sans-serif;
        display: flex;
        flex-direction: column;
        align-items: center;
        justify-content: center;
        padding: 8%;
        position: relative;
        overflow: hidden;
        text-align: center;
      }

      /* Floating shapes — continuous CSS animation in the canvas */
      .shape {
        position: absolute;
        border-radius: 50%;
        pointer-events: none;
        opacity: 0.12;
        background: #fff;
      }

      .shape-1 {
        width: 30%;
        aspect-ratio: 1;
        top: -8%;
        right: -8%;
        animation: float-1 calc(8s / var(--anim-speed, 1)) ease-in-out infinite;
      }

      .shape-2 {
        width: 20%;
        aspect-ratio: 1;
        bottom: -5%;
        left: -5%;
        animation: float-2 calc(10s / var(--anim-speed, 1)) ease-in-out infinite;
      }

      .shape-3 {
        width: 12%;
        aspect-ratio: 1;
        top: 25%;
        left: 15%;
        animation: float-3 calc(6s / var(--anim-speed, 1)) ease-in-out infinite;
      }

      .shape-4 {
        width: 8%;
        aspect-ratio: 1;
        bottom: 20%;
        right: 18%;
        animation: float-4 calc(7s / var(--anim-speed, 1)) ease-in-out infinite;
      }

      @keyframes float-1 {
        0%,
        100% {
          transform: translate(0, 0) scale(1);
        }
        50% {
          transform: translate(-15px, 20px) scale(1.08);
        }
      }

      @keyframes float-2 {
        0%,
        100% {
          transform: translate(0, 0) scale(1);
        }
        50% {
          transform: translate(20px, -15px) scale(1.05);
        }
      }

      @keyframes float-3 {
        0%,
        100% {
          transform: translate(0, 0);
        }
        33% {
          transform: translate(10px, -10px);
        }
        66% {
          transform: translate(-8px, 5px);
        }
      }

      @keyframes float-4 {
        0%,
        100% {
          transform: translate(0, 0);
        }
        50% {
          transform: translate(-12px, -8px);
        }
      }

      /* Message display with entrance animation */
      .message-display {
        position: relative;
        z-index: 1;
        max-width: 85%;
      }

      .message-text {
        font-size: clamp(18px, 3.5vw, 36px);
        font-weight: 800;
        color: #fff;
        line-height: 1.3;
        text-shadow: 0 2px 16px rgba(0, 0, 0, 0.2);
        word-wrap: break-word;
        overflow-wrap: break-word;
      }

      .message-cursor {
        display: inline-block;
        width: 3px;
        height: 0.9em;
        background: rgba(255, 255, 255, 0.8);
        margin-left: 2px;
        vertical-align: text-bottom;
        animation: cursor-blink 0.8s step-end infinite;
      }

      @keyframes cursor-blink {
        0%,
        100% {
          opacity: 1;
        }
        50% {
          opacity: 0;
        }
      }

      .message-author {
        margin-top: 1em;
        font-size: clamp(10px, 1.4vw, 14px);
        font-weight: 600;
        color: rgba(255, 255, 255, 0.6);
        letter-spacing: 0.05em;
      }

      /* ==========================================================
         Controls panel
         ========================================================== */
      .controls {
        background: #14141f;
        border-radius: 12px;
        padding: 1.25rem;
        border: 1px solid #282840;
        display: flex;
        flex-direction: column;
        gap: 0.75rem;
      }

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

      .control-row label {
        font-size: 0.7rem;
        font-weight: 600;
        text-transform: uppercase;
        letter-spacing: 0.04em;
        color: #6a6a80;
      }

      .control-row input[type="text"],
      .control-row textarea {
        width: 100%;
        padding: 0.5rem 0.75rem;
        font-size: 0.85rem;
        font-family: inherit;
        background: #1e1e2e;
        border: 1px solid #2a2a40;
        border-radius: 6px;
        color: #f0f0f0;
        outline: none;
        transition: border-color 0.15s ease;
        resize: vertical;
      }

      .control-row input[type="text"]:focus,
      .control-row textarea:focus {
        border-color: #6c41f0;
      }

      .speed-row {
        flex-direction: row;
        align-items: flex-end;
        gap: 0.75rem;
      }

      .speed-group {
        flex: 1;
        display: flex;
        flex-direction: column;
        gap: 0.3rem;
      }

      input[type="range"] {
        width: 100%;
        accent-color: #6c41f0;
      }

      /* Recording controls row */
      .rec-controls {
        display: flex;
        gap: 0.75rem;
        align-items: center;
        flex-wrap: wrap;
        margin-top: 0.25rem;
      }

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

      .btn:disabled {
        opacity: 0.4;
        cursor: not-allowed;
      }

      .btn:focus-visible {
        outline: 2px solid #6c41f0;
        outline-offset: 2px;
      }

      .btn-record {
        background: #d52e66;
        color: #fff;
        border-color: #d52e66;
      }

      .btn-record:hover:not(:disabled) {
        background: #e84080;
        border-color: #e84080;
      }

      .btn-stop {
        background: #1a1a2e;
        color: #f0f0f0;
        border-color: #ff4444;
      }

      .btn-stop:hover:not(:disabled) {
        background: #2a1a1e;
      }

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

      .btn-download:hover:not(:disabled) {
        background: #8562ff;
        border-color: #8562ff;
      }

      .btn-replay {
        background: #1a1a2e;
        color: #a0a0b8;
        border-color: #282840;
      }

      .btn-replay:hover:not(:disabled) {
        background: #242440;
        border-color: #3a3a58;
      }

      /* Status readout */
      .status {
        display: flex;
        gap: 1.5rem;
        align-items: center;
        flex-wrap: wrap;
      }

      .status-item {
        display: flex;
        flex-direction: column;
        gap: 0.1rem;
      }

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

      .status-value {
        font-size: 0.85rem;
        font-weight: 600;
        color: #a0a0b8;
        font-variant-numeric: tabular-nums;
      }

      .status-value.recording {
        color: #ff4444;
      }

      @media (max-width: 540px) {
        .speed-row {
          flex-direction: column;
        }

        .rec-controls {
          flex-direction: column;
          align-items: stretch;
        }
      }
    </style>
  </head>
  <body>
    <div class="recorder">
      <!-- ============================================
           CANVAS PREVIEW
           The <canvas> with layoutsubtree renders its
           child HTML — including live CSS animations.
           captureStream() records every frame to WebM.
           ============================================ -->
      <div class="preview">
        <canvas
          id="canvas"
          width="1280"
          height="720"
          layoutsubtree
          aria-label="Animated message preview"
        >
          <!--
            The entire animated scene is HTML + CSS.
            Floating shapes, gradient background, and
            the typed message are all rendered via
            drawElementImage(). captureStream() captures
            every painted frame as video.
          -->
          <div id="scene">
            <div class="shape shape-1"></div>
            <div class="shape shape-2"></div>
            <div class="shape shape-3"></div>
            <div class="shape shape-4"></div>
            <div class="message-display">
              <div class="message-text" id="message-text">
                Hello, world!
              </div>
              <div class="message-author" id="message-author">
                html-in-canvas.dev
              </div>
            </div>
          </div>
        </canvas>

        <div class="rec-badge" id="rec-badge">
          <span class="rec-dot"></span>
          <span id="rec-timer">0:00</span>
        </div>
      </div>

      <!-- ============================================
           CONTROLS
           Text input updates the scene's message in
           real time. Recording controls manage the
           captureStream/MediaRecorder pipeline.
           ============================================ -->
      <div class="controls">
        <div class="control-row">
          <label for="input-message">Message</label>
          <textarea
            id="input-message"
            rows="2"
            placeholder="Type something to display..."
          >Hello, world!</textarea>
        </div>

        <div class="control-row">
          <label for="input-author">Attribution</label>
          <input
            type="text"
            id="input-author"
            value="html-in-canvas.dev"
          />
        </div>

        <div class="control-row speed-row">
          <div class="speed-group">
            <label for="input-speed">
              Animation Speed <span id="speed-value">1.0</span>&times;
            </label>
            <input
              type="range"
              id="input-speed"
              min="0.2"
              max="3"
              step="0.1"
              value="1"
            />
          </div>
        </div>

        <div class="rec-controls">
          <button class="btn btn-record" id="btn-record" type="button">
            Record
          </button>
          <button class="btn btn-stop" id="btn-stop" type="button" disabled>
            Stop
          </button>
          <button
            class="btn btn-download"
            id="btn-download"
            type="button"
            disabled
          >
            Download WebM
          </button>
          <button
            class="btn btn-replay"
            id="btn-replay"
            type="button"
            disabled
          >
            Replay
          </button>
        </div>

        <div class="status">
          <div class="status-item">
            <span class="status-label">State</span>
            <span class="status-value" id="status-state">Idle</span>
          </div>
          <div class="status-item">
            <span class="status-label">Duration</span>
            <span class="status-value" id="status-duration">0:00</span>
          </div>
          <div class="status-item">
            <span class="status-label">File Size</span>
            <span class="status-value" id="status-size">&mdash;</span>
          </div>
        </div>
      </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);

      // =============================================================
      // CANVAS SETUP
      // =============================================================
      const canvas = $("canvas");
      const ctx = canvas.getContext("2d");
      const scene = $("scene");

      // =============================================================
      // PAINT CALLBACK
      // drawElementImage() renders the animated HTML scene — including
      // every frame of CSS animation (floating shapes, gradient hue
      // shift, cursor blink) — into the canvas. captureStream() then
      // captures these painted frames as video.
      // =============================================================
      function paintScene() {
        ctx.clearRect(0, 0, canvas.width, canvas.height);
        const transform = ctx.drawElementImage(scene, 0, 0);
        if (transform) {
          scene.style.transform = transform.toString();
        }
      }

      canvas.onpaint = paintScene;

      // =============================================================
      // RESIZE OBSERVER
      // Keep the bitmap sized 1:1 with CSS pixels. Chrome's
      // layoutsubtree uses canvas.width as the layout viewport for
      // children, so a DPR-scaled bitmap breaks child layout on
      // Retina displays (see project_layoutsubtree_bitmap_layout).
      // =============================================================
      const ro = new ResizeObserver(([entry]) => {
        const { width, height } = entry.contentRect;
        canvas.width = Math.round(width);
        canvas.height = Math.round(height);
        canvas.requestPaint?.();
      });
      ro.observe(canvas);

      // =============================================================
      // CONTINUOUS ANIMATION
      // Slowly shift the gradient hue to prove CSS animations are
      // faithfully captured frame-by-frame in the recorded video.
      // =============================================================
      let animationSpeed = 1;
      let hueOffset = 0;
      let lastTime = performance.now();
      let animFrameId = null;

      function animateScene(now) {
        const dt = (now - lastTime) / 1000;
        lastTime = now;

        // Shift gradient hue continuously
        hueOffset += dt * 15 * animationSpeed;
        scene.style.setProperty("--hue-offset", hueOffset.toFixed(1));

        // Request a canvas repaint to capture the new frame
        requestRepaint();

        animFrameId = requestAnimationFrame(animateScene);
      }

      animFrameId = requestAnimationFrame(animateScene);

      /** Request a canvas repaint after a DOM change. */
      function requestRepaint() {
        if (canvas.requestPaint) {
          canvas.requestPaint();
        } else if (canvas.onpaint) {
          canvas.onpaint();
        }
      }

      // =============================================================
      // CONTROLS -> SCENE UPDATES
      // =============================================================
      const inputMessage = $("input-message");
      const inputAuthor = $("input-author");
      const inputSpeed = $("input-speed");
      const speedValue = $("speed-value");
      const messageText = $("message-text");
      const messageAuthor = $("message-author");

      inputMessage.addEventListener("input", () => {
        messageText.textContent = inputMessage.value || "...";
        requestRepaint();
      });

      inputAuthor.addEventListener("input", () => {
        messageAuthor.textContent = inputAuthor.value;
        requestRepaint();
      });

      inputSpeed.addEventListener("input", () => {
        animationSpeed = parseFloat(inputSpeed.value);
        speedValue.textContent = animationSpeed.toFixed(1);

        // Adjust CSS animation speed on the floating shapes via custom property
        scene.style.setProperty("--anim-speed", animationSpeed);
      });

      // =============================================================
      // RECORDING — captureStream() + MediaRecorder
      //
      // This is the heart of the demo. canvas.captureStream() creates
      // a live MediaStream from the canvas output. MediaRecorder
      // encodes those frames into a WebM video in real time.
      //
      // Because drawElementImage() paints real HTML (with CSS
      // animations, transitions, and live updates) into the canvas,
      // the resulting video captures the FULL fidelity of the
      // animated HTML content — gradients, custom fonts, floating
      // shapes, everything.
      // =============================================================
      const btnRecord = $("btn-record");
      const btnStop = $("btn-stop");
      const btnDownload = $("btn-download");
      const btnReplay = $("btn-replay");
      const recBadge = $("rec-badge");
      const recTimer = $("rec-timer");
      const statusState = $("status-state");
      const statusDuration = $("status-duration");
      const statusSize = $("status-size");

      let mediaRecorder = null;
      let recordedChunks = [];
      let recordingStartTime = 0;
      let timerInterval = null;
      let lastBlobUrl = null;

      /** Format seconds as M:SS. */
      function formatTime(seconds) {
        const m = Math.floor(seconds / 60);
        const s = Math.floor(seconds % 60);
        return m + ":" + String(s).padStart(2, "0");
      }

      /** Format bytes as a human-readable string. */
      function formatBytes(bytes) {
        if (bytes < 1024) return bytes + " B";
        if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
        return (bytes / (1024 * 1024)).toFixed(2) + " MB";
      }

      /** Update the timer display. */
      function updateTimer() {
        const elapsed = (performance.now() - recordingStartTime) / 1000;
        const timeStr = formatTime(elapsed);
        recTimer.textContent = timeStr;
        statusDuration.textContent = timeStr;

        // Update file size estimate from chunks collected so far
        const totalBytes = recordedChunks.reduce(
          (sum, chunk) => sum + chunk.size,
          0
        );
        if (totalBytes > 0) {
          statusSize.textContent = formatBytes(totalBytes);
        }
      }

      // ---- START RECORDING ----
      btnRecord.addEventListener("click", () => {
        // Clean up any previous recording
        if (lastBlobUrl) {
          URL.revokeObjectURL(lastBlobUrl);
          lastBlobUrl = null;
        }

        recordedChunks = [];

        // captureStream(30) creates a 30 fps MediaStream from the canvas
        const stream = canvas.captureStream(30);

        // Find a supported MIME type for WebM video
        const mimeType = [
          "video/webm;codecs=vp9",
          "video/webm;codecs=vp8",
          "video/webm",
        ].find((type) => MediaRecorder.isTypeSupported(type));

        if (!mimeType) {
          statusState.textContent = "Error: WebM not supported";
          return;
        }

        mediaRecorder = new MediaRecorder(stream, {
          mimeType,
          videoBitsPerSecond: 2_500_000, // 2.5 Mbps for good quality
        });

        mediaRecorder.ondataavailable = (event) => {
          if (event.data.size > 0) {
            recordedChunks.push(event.data);
          }
        };

        mediaRecorder.onstop = () => {
          clearInterval(timerInterval);
          timerInterval = null;

          // Build the final blob
          const blob = new Blob(recordedChunks, { type: mimeType });
          lastBlobUrl = URL.createObjectURL(blob);

          // Update UI
          statusState.textContent = "Ready";
          statusSize.textContent = formatBytes(blob.size);
          recBadge.classList.remove("active");

          btnRecord.disabled = false;
          btnStop.disabled = true;
          btnDownload.disabled = false;
          btnReplay.disabled = false;
        };

        // Collect data every 250ms for live size updates
        mediaRecorder.start(250);
        recordingStartTime = performance.now();

        // Update timer every 100ms
        timerInterval = setInterval(updateTimer, 100);

        // Update UI
        statusState.textContent = "Recording";
        statusState.classList.add("recording");
        statusDuration.textContent = "0:00";
        statusSize.textContent = "0 B";
        recBadge.classList.add("active");

        btnRecord.disabled = true;
        btnStop.disabled = false;
        btnDownload.disabled = true;
        btnReplay.disabled = true;
      });

      // ---- STOP RECORDING ----
      btnStop.addEventListener("click", () => {
        if (mediaRecorder && mediaRecorder.state === "recording") {
          mediaRecorder.stop();
          statusState.textContent = "Finishing...";
          statusState.classList.remove("recording");
        }
      });

      // ---- DOWNLOAD ----
      btnDownload.addEventListener("click", () => {
        if (!lastBlobUrl) return;

        const a = document.createElement("a");
        a.href = lastBlobUrl;
        a.download = "html-canvas-recording.webm";
        document.body.appendChild(a);
        a.click();
        document.body.removeChild(a);
      });

      // ---- REPLAY (open in new tab) ----
      btnReplay.addEventListener("click", () => {
        if (!lastBlobUrl) return;
        window.open(lastBlobUrl, "_blank");
      });
    </script>
  </body>
</html>