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.