Build and inscribe a WebGL Ordinal.

This tutorial takes you from an empty folder to a standalone, responsive WebGL artwork, then through local testing, file-size checks, inscription, and verification.

one index.htmlno runtime librariesresponsive canvascopy-ready code
Jump to a section
Outcome

What you will build

The finished artifact is a single index.html file containing the canvas, JavaScript WebGL runtime, vertex shader, and fragment shader. The file renders directly in a browser and does not need Three.js, npm, a build system, or a web server.

Create
index.html
Compile
GLSL shaders
Test in
browser
Optimize
and inspect
Inscribe
and verify
Bitcoin transactions are irreversible. Test the final file, receiving address, fee rate, and wallet workflow before authorizing an inscription. This page explains the process; it does not custody funds or guarantee a third-party service.
Before starting

Prerequisites

Software

  • A plain-text editor such as VS Code, Zed, Sublime Text, or Notepad++.
  • A current Chrome, Firefox, Edge, or Safari browser with WebGL enabled.
  • Developer Tools access for reading shader and JavaScript errors.

Bitcoin

  • An Ordinals-aware wallet or a configured ord wallet.
  • A fresh receiving address intended for the inscription.
  • Enough BTC to cover the inscription service cost and network fees.
Do not use an exchange deposit address. Use a wallet and receiving address that can safely manage Ordinal inscriptions and the sat carrying the inscription.
Concept

How the one-file artwork works

JavaScript creates a WebGL context from the canvas, compiles one vertex shader and one fragment shader, links them into a GPU program, draws two triangles covering the screen, and updates uniforms every animation frame.

HTML
canvas
WebGL
context
Vertex
shader
Fragment
shader
Screen
pixels

The vertex shader only places a fullscreen rectangle. The fragment shader does the visible work by calculating a color independently for each pixel.

Step 1

Create the standalone HTML file

  1. Create a new folder for the artwork.
  2. Create a file named index.html.
  3. Paste the complete template below.
  4. Save the file before opening it in a browser.
index.html — complete responsive starter
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>WebGL Ordinal</title>
  <style>
    html, body {
      width: 100%;
      height: 100%;
      margin: 0;
      overflow: hidden;
      background: #000;
    }

    canvas {
      display: block;
      width: 100vw;
      height: 100vh;
    }
  </style>
</head>
<body>
  <canvas id="glcanvas" aria-label="Generative WebGL artwork"></canvas>

  <script>
    (() => {
      "use strict";

      const canvas = document.getElementById("glcanvas");
      const gl = canvas.getContext("webgl", {
        alpha: false,
        antialias: false,
        depth: false,
        stencil: false,
        preserveDrawingBuffer: false
      });

      if (!gl) {
        document.body.innerHTML =
          "<p style='color:white;font:16px sans-serif;padding:24px'>" +
          "WebGL could not be initialized on this browser or device." +
          "</p>";
        return;
      }

      const vertexSource = `
        attribute vec2 a_position;

        void main() {
          gl_Position = vec4(a_position, 0.0, 1.0);
        }
      `;

      const fragmentSource = `
        precision highp float;

        uniform vec2 u_resolution;
        uniform vec2 u_mouse;
        uniform float u_time;

        #define PI 3.14159265359

        void main() {
          vec2 uv = gl_FragCoord.xy / u_resolution.xy;
          vec2 p = uv - 0.5;

          // Preserve proportions on wide and tall displays.
          p.x *= u_resolution.x / u_resolution.y;

          float t = u_time * 0.32;
          float d = length(p);
          float angle = atan(p.y, p.x);

          float fieldA = sin((p.x * 10.0) + cos(p.y * 8.0 - t));
          float fieldB = cos((p.y * 13.0) - sin(p.x * 6.0 + t * 1.4));
          float rings = sin((d * 22.0) - t * 2.0 + angle * 2.0);
          float mouseField = sin(distance(uv, u_mouse) * 18.0 - t);

          vec3 colorA = vec3(0.05, 0.74, 0.92);
          vec3 colorB = vec3(0.92, 0.12, 0.54);
          vec3 colorC = vec3(0.96, 0.76, 0.18);

          float mixA = 0.5 + 0.5 * fieldA;
          float mixB = 0.5 + 0.5 * fieldB;

          vec3 color = mix(colorA, colorB, mixA);
          color = mix(color, colorC, mixB * 0.55);
          color += 0.12 * rings;
          color += 0.08 * mouseField;

          gl_FragColor = vec4(color, 1.0);
        }
      `;

      function compileShader(type, source) {
        const shader = gl.createShader(type);
        gl.shaderSource(shader, source);
        gl.compileShader(shader);

        if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
          const log = gl.getShaderInfoLog(shader);
          gl.deleteShader(shader);
          throw new Error(log || "Unknown shader compilation error.");
        }

        return shader;
      }

      function createProgram(vertexText, fragmentText) {
        const program = gl.createProgram();
        const vertexShader = compileShader(gl.VERTEX_SHADER, vertexText);
        const fragmentShader = compileShader(gl.FRAGMENT_SHADER, fragmentText);

        gl.attachShader(program, vertexShader);
        gl.attachShader(program, fragmentShader);
        gl.linkProgram(program);

        gl.deleteShader(vertexShader);
        gl.deleteShader(fragmentShader);

        if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
          const log = gl.getProgramInfoLog(program);
          gl.deleteProgram(program);
          throw new Error(log || "Unknown shader linking error.");
        }

        return program;
      }

      let program;

      try {
        program = createProgram(vertexSource, fragmentSource);
      } catch (error) {
        console.error(error);
        document.body.innerHTML =
          "<pre style='white-space:pre-wrap;color:#ff9b9b;background:#160000;" +
          "padding:24px;margin:0;min-height:100vh'>" +
          "Shader error:
" + String(error.message || error) +
          "</pre>";
        return;
      }

      const positions = new Float32Array([
        -1, -1,
         1, -1,
        -1,  1,
        -1,  1,
         1, -1,
         1,  1
      ]);

      const positionBuffer = gl.createBuffer();
      gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
      gl.bufferData(gl.ARRAY_BUFFER, positions, gl.STATIC_DRAW);

      const positionLocation = gl.getAttribLocation(program, "a_position");
      const resolutionLocation = gl.getUniformLocation(program, "u_resolution");
      const mouseLocation = gl.getUniformLocation(program, "u_mouse");
      const timeLocation = gl.getUniformLocation(program, "u_time");

      const mouse = { x: 0.5, y: 0.5 };

      function updatePointer(event) {
        const rect = canvas.getBoundingClientRect();
        mouse.x = (event.clientX - rect.left) / rect.width;
        mouse.y = 1.0 - (event.clientY - rect.top) / rect.height;
      }

      window.addEventListener("pointermove", updatePointer, { passive: true });

      function resizeCanvasToDisplaySize() {
        const dpr = Math.min(window.devicePixelRatio || 1, 2);
        const width = Math.max(1, Math.round(canvas.clientWidth * dpr));
        const height = Math.max(1, Math.round(canvas.clientHeight * dpr));

        if (canvas.width !== width || canvas.height !== height) {
          canvas.width = width;
          canvas.height = height;
        }
      }

      function render(milliseconds) {
        resizeCanvasToDisplaySize();

        gl.viewport(0, 0, canvas.width, canvas.height);
        gl.useProgram(program);

        gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
        gl.enableVertexAttribArray(positionLocation);
        gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0);

        gl.uniform2f(resolutionLocation, canvas.width, canvas.height);
        gl.uniform2f(mouseLocation, mouse.x, mouse.y);
        gl.uniform1f(timeLocation, milliseconds * 0.001);

        gl.drawArrays(gl.TRIANGLES, 0, 6);
        requestAnimationFrame(render);
      }

      canvas.addEventListener("webglcontextlost", (event) => {
        event.preventDefault();
        console.warn("WebGL context lost.");
      });

      canvas.addEventListener("webglcontextrestored", () => {
        window.location.reload();
      });

      requestAnimationFrame(render);
    })();
  </script>
</body>
</html>
This version fixes several limitations of the older template: it uses correct shader error reporting, responsive backing resolution, device-pixel-ratio handling, a full-screen triangle pair, time and mouse uniforms, and context-loss handling.
Step 2

Replace the fragment shader with your artwork

In the template, locate const fragmentSource = `...`;. Replace only the GLSL inside that template literal. Keep the uniform names aligned with the JavaScript runtime unless you also update the corresponding JavaScript locations.

fragment shader — minimal compatible example
precision highp float;

uniform vec2 u_resolution;
uniform vec2 u_mouse;
uniform float u_time;

#define PI 3.14159265359

void main() {
  vec2 uv = gl_FragCoord.xy / u_resolution.xy;
  vec2 p = uv - 0.5;
  p.x *= u_resolution.x / u_resolution.y;

  float t = u_time * 0.32;
  float d = length(p);
  float angle = atan(p.y, p.x);

  float fieldA = sin((p.x * 10.0) + cos(p.y * 8.0 - t));
  float fieldB = cos((p.y * 13.0) - sin(p.x * 6.0 + t * 1.4));
  float rings = sin((d * 22.0) - t * 2.0 + angle * 2.0);

  vec3 colorA = vec3(0.05, 0.74, 0.92);
  vec3 colorB = vec3(0.92, 0.12, 0.54);
  vec3 colorC = vec3(0.96, 0.76, 0.18);

  vec3 color = mix(colorA, colorB, 0.5 + 0.5 * fieldA);
  color = mix(color, colorC, (0.5 + 0.5 * fieldB) * 0.55);
  color += 0.12 * rings;

  gl_FragColor = vec4(color, 1.0);
}

The three uniforms

UniformTypeMeaning
u_resolutionvec2Actual canvas width and height in backing pixels.
u_timefloatElapsed animation time in seconds.
u_mousevec2Normalized pointer position from 0.0 to 1.0.
Porting from ShaderToy: map iResolution.xy to u_resolution, iTime to u_time, and normalized mouse input to u_mouse. Remove ShaderToy-only channels unless you inscribe or recursively reference those resources.
Step 3

Test locally before touching Bitcoin

  1. Double-click index.html or drag it into the browser.
  2. Resize the window and rotate a mobile device to confirm the shader stays proportional.
  3. Open Developer Tools and inspect the Console.
  4. Move the pointer to confirm interactive uniforms update.
  5. Leave the page running for several minutes to catch context or performance problems.

Expected result

You should see an animated, full-window abstract field with no scrollbars and no console errors. The image should fill the viewport without appearing horizontally stretched.

A visual result is not enough. A shader can render while still producing warnings, losing precision on some GPUs, or depending on browser behavior that will not survive an inscription sandbox. Read the console and test more than one browser.
Step 4

Preflight the final inscription file

  • The file is named clearly and opens directly without a build step.
  • Every required script and shader is embedded inside the HTML.
  • There are no ordinary off-chain image, font, audio, CSS, or JavaScript requests.
  • The shader compiles with no console errors in at least two browsers.
  • The canvas handles desktop, mobile, portrait, landscape, and high-DPI screens.
  • The artwork remains usable without keyboard, pointer, or audio input.
  • The receiving address belongs to an Ordinals-aware wallet you control.
  • You saved an exact local copy and a checksum of the file being inscribed.

Create a checksum

Terminal — optional integrity check
shasum -a 256 index.html
# Windows PowerShell:
Get-FileHash .\index.html -Algorithm SHA256

Save the checksum beside your project notes. It gives you a simple way to prove which local file corresponds to the inscribed payload.

Step 5

Check file size and estimate fees

Larger files cost more because inscription content is included in transaction witness data. For the standard ord wallet path, the official guide states that inscription transactions must remain below 400,000 weight units for standard relay and recommends keeping content below roughly 390,000 bytes to leave transaction overhead.

Do not treat “4 MB” as the working file target. Bitcoin’s block-weight ceiling, transaction relay policy, the inscription tool, and the selected workflow are different constraints. Use the limit shown by your actual tool and keep a meaningful safety margin.

Measure the file

Terminal — file size
wc -c index.html
# Human-readable:
ls -lh index.html
# Windows PowerShell:
(Get-Item .\index.html).Length

Reduce size without changing the artwork

  • Remove comments, unused functions, disabled shader experiments, and debug output.
  • Shorten internal variable names only after the shader is final and backed up.
  • Avoid external libraries when a few lines of native WebGL can do the same work.
  • Do not minify blindly; retest the exact optimized file in the browser.
Step 6

Inscribe with LooksOrdinal

LooksOrdinal currently presents a self-custodial inscription interface with a receiving address field, standard file inscriptions, fee-rate controls, fee estimation, and optional advanced settings.

  1. Open LooksOrdinal in Chrome or Firefox; its current page warns that Safari may not work.
  2. Paste the Ordinals-compatible receiving address from your wallet.
  3. Choose Standard, then files.
  4. Select the exact tested index.html.
  5. Review whether the file has already been inscribed if the interface offers that check.
  6. Select a fee rate based on current network conditions and your urgency.
  7. Use Estimate Fees before starting.
  8. Review padding, CPFP, turbo, backup, and tip options rather than enabling them blindly.
  9. Start the inscription and follow the displayed wallet/payment instructions.
  10. Keep the browser page open while the service explicitly tells you to do so.
Verify every address on-screen and in the wallet. Copy/paste errors, clipboard malware, an exchange address, or a non-Ordinals-aware wallet can permanently misdirect the inscription or its carrier sat.

Track the transactions

An inscription uses a commit transaction followed by a reveal transaction. Use the transaction links supplied by the service and inspect them in a Bitcoin block explorer such as mempool.space. The inscription becomes available after the reveal is mined and indexed.

Step 7

Optional: inscribe with the ord wallet CLI

The command-line path is useful when you run Bitcoin Core and ord, control the wallet directly, and want the official workflow rather than a hosted interface.

ord wallet — core command sequence
# Fund the ord wallet first.
ord wallet receive
ord wallet outputs

# Inscribe the completed standalone HTML file.
ord wallet inscribe \
  --fee-rate <SATS_PER_VBYTE> \
  --file index.html

# Check the wallet after the reveal confirms.
ord wallet inscriptions
The CLI returns commit and reveal transaction IDs plus the inscription ID. The receiving, funding, indexing, wallet safety, and Bitcoin Core setup requirements are beyond this single-file WebGL tutorial; use the current Ordinal Theory Handbook for the complete node and wallet procedure.
Step 8

Verify the on-chain result

  1. Wait for the reveal transaction to confirm.
  2. Open the inscription in an Ordinals-compatible explorer.
  3. Open its direct content view, not only a marketplace preview.
  4. Confirm animation, resolution, pointer behavior, and mobile layout.
  5. Compare the displayed content with your local copy and saved checksum.
  6. Record the inscription ID, inscription number, reveal transaction, sat number, and receiving address.

Understanding the inscription ID

The ID uses the form TXIDiN: the transaction ID of the reveal followed by i and the inscription’s index inside that reveal transaction.

Diagnostics

Troubleshooting

ProblemLikely causeAction
Black or blank canvas Shader compile failure, WebGL unavailable, or program link failure. Open the console. The starter template prints the exact compiler/linker log.
precision error The fragment shader has no floating-point precision declaration. Add precision highp float; or fall back to mediump when required.
Artwork stretches on wide screens Coordinates ignore aspect ratio. Multiply centered p.x by u_resolution.x / u_resolution.y.
Animation is too fast Milliseconds were used directly. Convert the animation timestamp to seconds with * 0.001.
Looks different after inscription Off-chain resources were blocked or explorer sandbox behavior differs. Make the file self-contained or use supported recursive on-chain endpoints.
Transaction remains pending Fee rate is below current demand. Inspect commit and reveal separately; use CPFP only when the chosen workflow supports it and you understand the cost.
File rejected as too large Tool or standard relay limit exceeded. Reduce the file, use recursion for shared on-chain resources, or follow the tool’s documented alternative workflow.
Before authorizing

Final checklist

  • I am inscribing the exact final file I tested.
  • The file is self-contained or uses only intentional recursive on-chain references.
  • The shader compiles in multiple current browsers.
  • The file is below the limit shown by my chosen workflow.
  • The receiving address belongs to an Ordinals-aware wallet I control.
  • I reviewed the current fee rate and total estimated cost.
  • I recorded the local SHA-256 checksum.
  • I understand the commit/reveal sequence and will verify both transactions.
Once every item is true, the file is ready for the inscription workflow.