---
title: "The Prompt I Used to Build a Fluid Ink-Reveal Homepage, with a Higgsfield-Generated Wordmark Video"
description: "A copy-pasteable prompt for a homepage where dragging the cursor spreads ink that punches through the page to a looping video of the same word morphing through 3D materials — a WebGL Navier-Stokes fluid as the mask, and the video generated with the Higgsfield MCP."
date: "2026-07-29"
type: "prompt"
reel: "DbYR8HdBdml"
tags: []
legacy: true
---
<h2>Overview</h2>
      <p>
        A white page with one enormous black word on it. Move the cursor and ink
        spreads out from under it — not a circle following the mouse, actual
        fluid, curling and trailing off. Wherever the ink goes, the white page
        <em>and the black lettering</em> are both erased, and underneath is the
        same word rendered in 3D: white fur, then clear glass, then iridescent
        purple, then champagne gold, changing every couple of seconds.
      </p>
      <p>
        Because the video behind the page has a pure black background, the
        revealed area reads as a black ink blot with a rendered object sitting
        inside it. Half flat type, half physical thing. That contrast is the
        entire effect, and it is why the trick is worth the trouble.
      </p>
      <p>
        Two prompts below: one for generating the wordmark video with the
        <a href="https://higgsfield.ai" rel="nofollow">Higgsfield</a> MCP, one
        for the page that reveals it. The interaction is the kind you see on
        studio sites like <a href="https://noth.in" rel="nofollow">noth.in</a>.
      </p>

      <div class="highlight">
        <p>
          <strong>What it produces:</strong> a single screen, no scrolling. Heavy
          black wordmark centred on white, small interface in the four corners,
          and a GPU fluid simulation as the reveal mask. Idle for a few seconds
          and the ink drifts on its own so the video stays discoverable.
        </p>
        <p style="margin-bottom: 0">
          <strong>Stack:</strong> one HTML file, one CSS file, one JS file, raw
          WebGL2 — no Three.js, no shader library. Plus a 15-second video you
          generate first.
        </p>
      </div>

      <h2>Prompt 1 — the wordmark video</h2>
      <p>
        This runs against the Higgsfield MCP server in two passes: a still,
        then an image-to-video morph off that still. Doing it in one text-to-video
        pass does not work — the lettering drifts and the spelling falls apart.
        Swap <code>AVI.</code> for your own word.
      </p>

      <div class="prompt">Use the Higgsfield MCP to generate a looping wordmark video, in two passes.

PASS 1 — MASTER STILL (text-to-image, 16:9)
Generate one still with this prompt:

  "A 3D typographic wordmark on a pure #000000 black background. The word reads
  exactly: AVI. — three uppercase Latin letters A, V, I followed by a full stop.
  Spelling must be exactly A-V-I-period, no other letters, no extra characters.
  The lettering is an extremely heavy geometric grotesque sans-serif, ultra-bold,
  tight letter spacing, letters nearly touching. The wordmark is HUGE: it spans
  edge to edge across the full width of the frame, almost touching the left and
  right edges, perfectly centred vertically with generous black space above and
  below. Locked-off straight-on front view, no perspective, no rotation, no tilt.
  Material: thick soft pure white fluffy cotton fibre, dense plush fur, downy
  shag texture, fine white filaments catching a soft studio key light.
  Studio product photography, crisp focus edge to edge, high detail.
  Negative: no smoke, no haze, no mist, no fog, no glow, no light rays, no lens
  flare, no particles, no floor, no reflection, no shadow cast on background, no
  gradient background, no vignette, no extra text, no watermark, no logo."

Then VERIFY the still before spending anything on video: download it, threshold
it, and check the ink bounding box actually spans ~90%+ of the frame width and is
vertically centred. Check the spelling by looking at it. Regenerate if not.

PASS 2 — MATERIAL MORPH (image-to-video, 15s, 1080p)
Pass the still's job_id as start_image. Prompt:

  "Locked-off static camera. No zoom, no pan, no push-in, no rotation. The camera
  never moves. The wordmark reads AVI. and keeps its EXACT letterforms, spelling,
  size, position, baseline and framing identical in every frame, edge to edge,
  vertically centred, on a pure #000000 black background. The letters never move,
  never scale, never deform. ONLY the surface material of the letters changes.
  The surface finish transforms in five distinct beats, each sweeping across the
  letters from one side and fully replacing the previous one, roughly every two
  and a half seconds:
  Beat 1 — soft pure white fluffy cotton fibre, plush downy shag.
  Beat 2 — thick clear transparent polished glass, bright specular highlights.
  Beat 3 — iridescent violet-purple metallic finish, crinkled reflective sheen.
  Beat 4 — warm champagne gold chrome, soft brushed metallic reflections.
  Beat 5 — deep black high-gloss lacquer with sharp white specular highlights.
  In the final two seconds the surface returns completely to the exact soft pure
  white fluffy cotton fibre of the opening frame, so the last frame matches the
  first frame for a seamless loop.
  These are SURFACE FINISHES on solid sculpted letters, not physical objects. No
  inflating, no deflating, no nozzles, no valves, no seams, no ribbons, no props.
  Negative: no smoke, no haze, no mist, no fog, no glow, no light rays, no lens
  flare, no particles, no dust, no floor, no shadow on the background, no
  gradient background, no vignette, no camera movement, no extra text, no letters
  other than A V I and a period, no watermark."

MODEL CHOICE
Call models_explore first rather than assuming. get_cost does NOT check plan
entitlement — it will happily quote a price for a model that then 403s. On a free
plan, seedance_2_0 requires Pro, kling3_0_turbo requires Basic, and
nano_banana_pro with resolution:"4k" requires Plus. happy_horse_video runs on
free at 15s/1080p and holds letterforms fine. Try the best model available and
fall back rather than stopping.

THEN MAKE IT LOOP
The model returns to the opening material but not to the exact opening frame, so
a hard cut jumps. Cross-dissolve the tail over the head with F ≈ 0.75s of frames:

  out[i] = lerp(src[N-F+i], src[i], i/F)   for i < F
  out[i] = src[i]                          for i >= F

Output length N-F, so the last frame's natural successor is exactly what frame 0
blends from and the seam lands mid-dissolve. Verify numerically, not by eye:
mean absolute difference last-to-first must be LOWER than a typical
consecutive-frame difference in the same clip. Use opencv-python; if there is no
system ffmpeg, imageio-ffmpeg ships its own binary.

Finally, measure where the lettering sits inside the finished video frame as
fractions of width and height, and report those four numbers — the page needs
them to line the video up with its own type.</div>

      <h2>Prompt 2 — the page</h2>
      <p>
        Feed it the four <code>MARK</code> numbers from the step above.
      </p>

      <div class="prompt">Build a single-screen homepage (index.html + one CSS file + one JS file, raw WebGL2, no Three.js, no shader library) where moving the cursor spreads ink that punches through the page to reveal a video underneath.

LAYOUT
- White page, no scrolling. A very heavy black grotesque wordmark ("AVI."), drawn
  CENTRED, filling ~70% of viewport width. Use Archivo Black or similar, tracking
  about -0.03em.
- Small interface in the four corners, above the reveal: two-line intro and a
  black pill button with an arrow top-left; vertical nav top-right; one-line
  colophon bottom-left; social links and a language badge bottom-right.
- Visually-hidden h1 with the real text, since the wordmark is pixels.

THREE LAYERS — this is the core of it
  z0  &lt;video&gt;   the wordmark video, muted loop playsinline autoplay, black backdrop behind it
  z1  &lt;canvas&gt;  the white page + the black wordmark
  z2  .chrome   the interface
The canvas writes alpha = 1 - mask. Where ink has spread, the canvas goes
transparent and the video shows through.
- Draw the wordmark INTO the canvas, not as HTML — it has to be erased along with
  the white, and CSS cannot mask by a live canvas. Render it to an offscreen 2D
  canvas, upload as an RGBA8 texture, re-upload on resize.
- Keep the video a DOM element positioned with CSS. Do NOT sample it as a GL
  texture: leaving it in the DOM means plain CSS handles aligning its lettering
  with the drawn wordmark, and the shader never needs to know where it is.
- Set font-display: block and await document.fonts.load() before the first
  layout. The canvas MEASURES the font, so a swap would silently lay out against
  the fallback.

THE REVEAL — a real fluid simulation, not a blob trail
Implement Stam's stable fluids on the GPU. Per frame:
  advect velocity -> curl -> vorticity confinement -> divergence
    -> Jacobi pressure solve (20 iterations) -> subtract gradient -> advect dye
- Ping-pong FBOs for velocity/dye/pressure, single FBOs for divergence and curl.
  RG16F velocity, RGBA16F dye, R16F pressure, via EXT_color_buffer_float. Probe
  each format with a 4x4 test FBO and checkFramebufferStatus before committing,
  and narrow to RGBA16F if the driver refuses RG/R. If OES_texture_float_linear
  is missing, do bilinear sampling manually in the advection shader.
- Threshold the dye into the mask and output premultiplied:
      float d = texture2D(uDye, vUv).r;
      float mask = smoothstep(lo, hi, d);
      float a = 1.0 - mask;
      gl_FragColor = vec4(texture2D(uPaper, vUv).rgb * a, a);
  One fullscreen draw over a cleared buffer, so disable blending entirely.
- Scale the sim and dye grids by viewport aspect or the fluid comes out stretched.

MAKE IT FEEL INSTANT — do not skip any of this
- Splat velocity and dye AT the cursor position, with no easing and no delay.
- INTERPOLATE ALONG THE STROKE: on each pointermove, walk from the previous
  position to the current one and splat every ~0.006 normalised units. Without
  this a fast flick lands as a row of separate dots.
- Derive splat velocity from the pointer delta (delta * SPLAT_FORCE).
- Mind the y-flip: texture v goes up, clientY goes down. So
  point.y = 1 - clientY/height and velocity.y = -dy.
- Clamp dt to 1/30 so a backgrounded tab does not explode the sim on return.
- Starting values: SIM_RES 128, DYE_RES 1024, PRESSURE_ITERATIONS 20,
  PRESSURE 0.8, CURL 22, DENSITY_DISSIPATION 2.1, VELOCITY_DISSIPATION 1.4,
  SPLAT_RADIUS 0.0017, SPLAT_FORCE 2600, SPLAT_SPACING 0.006,
  MASK_LO 0.10, MASK_HI 0.34.
- Idle drift: after ~2.6s with no input, drive the fluid along a slow path so the
  video stays discoverable. Key that on IDLENESS ALONE, never on (hover: none) —
  gating it on the media query makes a touch device drift while being touched,
  and the drift then fights the user's own input.

DO NOT use the canvas2D "goo" trick (blur blobs, then composite the layer over
itself to sharpen alpha). Repeatedly compositing an 8-bit alpha channel over
itself quantises the ramp into visible stair-steps, and it is slow. If you must
avoid WebGL, draw a union of opaque overlapping arcs instead.

ALIGNING THE VIDEO WITH THE DRAWN WORDMARK
MARK is where the lettering sits inside the video frame, as fractions:
  const MARK = { x0: 0.036, y0: 0.139, x1: 0.975, y1: 0.874 };
Size the video so its lettering spans the same width as the drawn wordmark,
preserving aspect, centred vertically on the drawn mark:
  const dispW = (textRect.x1 - textRect.x0) / (MARK.x1 - MARK.x0);
  const dispH = dispW * (videoH / videoW);
  left = textRect.x0 - MARK.x0 * dispW;
  top  = (textRect.y0 + textRect.y1)/2 - ((MARK.y0 + MARK.y1)/2) * dispH;
Match on WIDTH, not height — left/right alignment is what the eye reads. The
rendered 3D letters are chunkier than any flat webfont so they will sit taller
than the drawn ones; that mismatch is what produces the half-flat/half-3D look.
Do not correct it with a non-uniform scale.

ALSO
- Responsive: recompute type size, re-upload the paper texture, re-place the
  video and rebuild the sim targets on resize and orientationchange.
- Size the canvas by devicePixelRatio capped at 2.
- Autoplay: muted playsinline autoplay, plus retry play() on canplay, on first
  pointerdown, and on visibilitychange.
- prefers-reduced-motion: pause the video, skip the drift.
- Expose every fluid constant in one CFG object at the top of the JS file.
- Ship a non-fluid fallback (a plain radial reveal) for missing WebGL or missing
  renderable float textures, and log which was missing.

SERVE IT over HTTP, not file:// — that blocks the webfont fetch, and the canvas
measures that font. The server MUST implement HTTP byte ranges (206,
Accept-Ranges, Content-Range); Python's stock SimpleHTTPRequestHandler ignores
Range, which makes Safari refuse to play the video at all.

VERIFY by driving a real browser, not by eyeballing it: puppeteer-core against
installed Chrome with --enable-unsafe-swiftshader --use-gl=angle so WebGL works
headless. One trap: page.mouse.move costs ~70ms per call over CDP, which is
slower than the ink's lifetime, so the head of a scripted stroke fades before the
screenshot and you will "find" a bug that is not there. Dispatch the whole path
with a single in-page evaluate firing synthetic PointerEvents.</div>

      <h2>Why the prompts are written this way</h2>
      <p>
        Almost every long clause above exists because something went wrong
        without it. Six are load-bearing.
      </p>

      <h3>The wordmark has to be drawn into the canvas</h3>
      <p>
        The instinct is to leave the type as HTML and mask a white overlay over
        the video. It does not work, because the black lettering has to be erased
        <em>too</em> — ink passing over a letter must reveal the 3D version of
        that letter, not leave the flat one sitting on top of it. And CSS cannot
        use a live canvas as a mask, so the moment the mask is dynamic the text
        has to move into the canvas with it.
      </p>
      <p>
        The consequence is easy to miss: the canvas now <em>measures</em> the
        webfont to lay out. With <code>font-display: swap</code> it will happily
        measure the fallback face, compute a size from that, and paint the real
        font at the wrong scale — with no error anywhere. Hence
        <code>block</code> plus an explicit <code>document.fonts.load()</code>.
      </p>

      <h3>Keep the video in the DOM</h3>
      <p>
        Sampling the video as a GL texture is the more obvious architecture and
        it is the wrong one. The hard part of this build is not the fluid, it is
        getting the video's lettering to line up with the type you drew. If the
        video stays a DOM element, that is four lines of CSS arithmetic you can
        inspect in devtools. If it becomes a texture, the same problem turns into
        UV transforms inside a shader you cannot step through.
      </p>

      <h3>Interpolate along the stroke</h3>
      <p>
        This one clause is the difference between "responsive" and "broken". A
        <code>pointermove</code> fires at most once per frame, so a fast flick
        across the screen gives you five or six positions a hundred pixels
        apart. Splat once per event and you get a dotted line. Walk the segment
        and splat every ~0.006 units and you get a continuous stroke.
      </p>
      <p>
        My first version of this page did the opposite of instant: it spawned a
        blob only every 14px of cursor travel and then eased each one in over
        260ms. It was <em>designed</em> to lag, and no amount of tuning fixed it
        — the fix was deleting the easing.
      </p>

      <h3>The goo trick posterises</h3>
      <p>
        The standard canvas2D way to get an organic blob is to draw blurred
        circles and then composite the layer over itself a few times, driving
        alpha toward <code>1-(1-a)^(2^n)</code> to sharpen the edge. It is a good
        trick in SVG, where <code>feColorMatrix</code> works in higher precision.
      </p>
      <p>
        In a canvas it falls apart: you are compositing an
        <strong>8-bit</strong> alpha channel over itself, so the smooth blur ramp
        quantises into visible stair-steps. It looked like a driver bug when I
        first saw it. It is arithmetic. Overlapping opaque arcs are already
        antialiased, already merge seamlessly, and cost a fraction as much — and
        a real fluid sim is better than either.
      </p>

      <h3>Name the surface, never the object</h3>
      <p>
        In the video prompt, "translucent balloon latex" does not give you a
        balloon-like <em>finish</em> on your letters. It grows an actual balloon,
        with a nozzle, which then swallows the word. Video models read material
        nouns as things to place in the scene.
      </p>
      <p>
        Say "translucent frosted glass with an iridescent sheen" instead — a
        surface, not an object — and the letterforms survive. The explicit
        "these are SURFACE FINISHES on solid sculpted letters, not physical
        objects" line is doing real work.
      </p>

      <h3>The negative list is not boilerplate</h3>
      <p>
        Video models like to add atmosphere to an empty black frame: a faint
        smoke plume, a soft glow, drifting particles. On black it is invisible,
        so it passes review. Then you put the plate on a white page and the haze
        renders as a grey smudge floating in mid-air. Every one of
        <em>no smoke, no haze, no mist, no fog, no glow, no light rays, no
        particles</em> is there because of a clip I had to throw away.
      </p>

      <h2>Making it loop, and why you should measure it</h2>
      <p>
        Ask for a return to the opening material and you will get one — but not a
        return to the opening <em>frame</em>. Mine came back within a few RGB
        points of where it started and still visibly jumped, because the letters
        had drifted a dozen pixels in scale.
      </p>
      <p>
        The cross-dissolve in the prompt fixes it, and the test for it is worth
        stealing: don't ask "does the loop look smooth", ask whether the
        last-to-first frame difference is <em>smaller than a normal
        frame-to-frame difference in the same clip</em>. Mine came out at
        0.98/255 against a typical 1.63 — meaning the loop point is objectively
        less of a jump than ordinary motion in the video, so there is nothing
        left for an eye to catch.
      </p>

      <h2>Knobs to turn first</h2>
      <ul>
        <li>
          <strong>DENSITY_DISSIPATION (2.1).</strong> How fast ink fades. Lower
          for a long lingering trail, higher for a tight one. The single most
          expressive number in the file.
        </li>
        <li>
          <strong>CURL (22).</strong> Vorticity confinement — how much the ink
          curls and throws off wisps. High values look like smoke, low values
          like a plain blob.
        </li>
        <li>
          <strong>VELOCITY_DISSIPATION (1.4).</strong> Reach for this if ink
          keeps flying past where you actually drew. My first pass had it at
          0.28 and the dye ended up in a corner of the screen I had never
          touched.
        </li>
        <li>
          <strong>MASK_LO / MASK_HI (0.10 / 0.34).</strong> Raise both together
          to tighten the reveal to the dense core of the ink.
        </li>
        <li>
          <strong>SIDE_MARGIN.</strong> Wordmark size, as the fraction of
          viewport width left clear each side. Because the video is derived from
          the text rect, changing this rescales both together.
        </li>
      </ul>

      <h2>If it comes out wrong</h2>
      <ul>
        <li>
          <strong>The ink lags the cursor.</strong> Something is easing the
          splats in. There should be no interpolation in time at all — only in
          space, along the stroke.
        </li>
        <li>
          <strong>A fast flick leaves a dotted line.</strong> The stroke
          interpolation is missing.
        </li>
        <li>
          <strong>The reveal shows nothing over the lettering.</strong> This is
          usually correct and not a bug. Black text over a black video
          background <em>is</em> no visible change; you only see the effect where
          the ink crosses white space or a lit 3D letter.
        </li>
        <li>
          <strong>Stair-stepped blob edges.</strong> The 8-bit alpha
          posterisation above. Stop compositing the mask over itself.
        </li>
        <li>
          <strong>The fluid looks squashed.</strong> Sim grids are not scaled by
          viewport aspect.
        </li>
        <li>
          <strong>Everything is a white rectangle.</strong> A float format was
          rejected and the FBOs are incomplete. This is what the 4x4 probe is
          for — check the console for which format failed.
        </li>
        <li>
          <strong>Video plays in Chrome but not Safari.</strong> The server is
          ignoring <code>Range</code> headers. Safari will not play a video it
          cannot seek into.
        </li>
        <li>
          <strong>The type is subtly the wrong size.</strong> The canvas measured
          the fallback font. Check <code>font-display</code>.
        </li>
      </ul>

      <h2>Recap</h2>
      <ul>
        <li>
          Draw the wordmark into the canvas so the ink erases the page and the
          lettering together — then remember the canvas measures the webfont.
        </li>
        <li>
          Leave the video in the DOM. Registration is CSS arithmetic you can
          inspect, not UV maths you cannot.
        </li>
        <li>
          Splat with zero easing, and interpolate along the stroke. That is the
          whole feel of it.
        </li>
        <li>
          Generate the video as still-then-morph, never one text-to-video pass,
          and verify the still before paying for the video.
        </li>
        <li>
          Name surfaces, not objects, or a balloon will eat your word.
        </li>
        <li>
          Cross-dissolve the loop, then prove it with numbers rather than
          trusting your eye.
        </li>
      </ul>

      <p>
        If you want more prompts in this shape, there is
        <a href="/blog/prompt-frosted-glass-refraction-cube-webgl"
          >a frosted-glass refraction cube in raw WebGL</a>,
        <a href="/blog/build-drivable-3d-car-game-react-three-fiber"
          >a drivable 3D car game</a>, and
        <a href="/blog/3d-ascii-wordmark-svg-github-readme"
          >a rotating 3D ASCII wordmark with no JavaScript</a>.
      </p>
