← Learn · Prompt · 29 July 2026 · 15 min

The Prompt I Used to Build a Fluid Ink-Reveal Homepage, with a Higgsfield-Generated Wordmark Video

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.

Overview

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 and the black lettering 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.

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.

Two prompts below: one for generating the wordmark video with the Higgsfield MCP, one for the page that reveals it. The interaction is the kind you see on studio sites like noth.in.

What it produces: 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.

Stack: 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.

Prompt 1 — the wordmark video

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 AVI. for your own word.

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.

Prompt 2 — the page

Feed it the four MARK numbers from the step above.

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 <video> the wordmark video, muted loop playsinline autoplay, black backdrop behind it z1 <canvas> 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.

Why the prompts are written this way

Almost every long clause above exists because something went wrong without it. Six are load-bearing.

The wordmark has to be drawn into the canvas

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 too — 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.

The consequence is easy to miss: the canvas now measures the webfont to lay out. With font-display: swap 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 block plus an explicit document.fonts.load().

Keep the video in the DOM

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.

Interpolate along the stroke

This one clause is the difference between "responsive" and "broken". A pointermove 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.

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 designed to lag, and no amount of tuning fixed it — the fix was deleting the easing.

The goo trick posterises

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 1-(1-a)^(2^n) to sharpen the edge. It is a good trick in SVG, where feColorMatrix works in higher precision.

In a canvas it falls apart: you are compositing an 8-bit 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.

Name the surface, never the object

In the video prompt, "translucent balloon latex" does not give you a balloon-like finish 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.

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.

The negative list is not boilerplate

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 no smoke, no haze, no mist, no fog, no glow, no light rays, no particles is there because of a clip I had to throw away.

Making it loop, and why you should measure it

Ask for a return to the opening material and you will get one — but not a return to the opening frame. 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.

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 smaller than a normal frame-to-frame difference in the same clip. 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.

Knobs to turn first

  • DENSITY_DISSIPATION (2.1). How fast ink fades. Lower for a long lingering trail, higher for a tight one. The single most expressive number in the file.
  • CURL (22). Vorticity confinement — how much the ink curls and throws off wisps. High values look like smoke, low values like a plain blob.
  • VELOCITY_DISSIPATION (1.4). 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.
  • MASK_LO / MASK_HI (0.10 / 0.34). Raise both together to tighten the reveal to the dense core of the ink.
  • SIDE_MARGIN. 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.

If it comes out wrong

  • The ink lags the cursor. Something is easing the splats in. There should be no interpolation in time at all — only in space, along the stroke.
  • A fast flick leaves a dotted line. The stroke interpolation is missing.
  • The reveal shows nothing over the lettering. This is usually correct and not a bug. Black text over a black video background is no visible change; you only see the effect where the ink crosses white space or a lit 3D letter.
  • Stair-stepped blob edges. The 8-bit alpha posterisation above. Stop compositing the mask over itself.
  • The fluid looks squashed. Sim grids are not scaled by viewport aspect.
  • Everything is a white rectangle. 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.
  • Video plays in Chrome but not Safari. The server is ignoring Range headers. Safari will not play a video it cannot seek into.
  • The type is subtly the wrong size. The canvas measured the fallback font. Check font-display.

Recap

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

If you want more prompts in this shape, there is a frosted-glass refraction cube in raw WebGL, a drivable 3D car game, and a rotating 3D ASCII wordmark with no JavaScript.