Fluid Cursor

Coloured ink behind a region's content, stirred by the pointer: a move lays dye and momentum into a fluid, and the dye swirls into curling eddies, folds over itself, drifts on with the motion the pointer gave it and fades out over a few seconds. A fast stroke leaves a long sweeping trail with vortices peeling off; a slow one soft billows; a click a radial splash.

Installation

Import the class, and @use the entry point in your global styles. Nothing warns you when the @use is missing.

import { Component } from '@angular/core';
import { WrFluidCursor } from 'ngwr/animations/fluid-cursor';

@Component({ imports: [WrFluidCursor] })
export class MyComponent {}

Runs on the GPU

It simulates and draws on the GPU through WebGL, at a capped resolution. Nothing is created before the first move, and the loop stops once the ink has faded, and while the box is off screen or the tab is hidden. Without WebGL it draws nothing. Use one WebGL effect per view, and keep it off low-end and battery-sensitive pages if in doubt.

Basic

Put <wr-fluid-cursor /> first inside the region it should fill. The parent takes the pointer, passively: the host and its canvas never do, and a finger that draws is never stopped from scrolling. The fluid is a stable-fluids solver in WebGL, one canvas; the context is made at the first move, not at mount, and the frame loop runs only while the host is on screen, the document is visible and the last move is nearer than trail, then stops on its own. Move the pointer over the box; click in it.

<footer class="site-footer">
  <wr-fluid-cursor />
  …
</footer>

Move the pointer here.

The ink swirls on after the pointer stops and is gone in a few seconds.

The parent

The host is absolute, inset: 0, at z-index: -1, under no pointer and masked out at the edges, so ink carried to the border dissolves rather than ending in a straight cut. The parent provides two things: it is positioned, so the host fills it, and isolated, so the host's negative z-index lands at the floor of the parent's own stacking context, under the content and over the page background.

.site-footer {
  position: relative;
  isolation: isolate;
}

Colours

A stroke cycles through --wr-fluid-cursor-color-1 to -5, primary, danger, warning, success and info by default, one full cycle every 900px of travel; set them on the host, where the defaults are declared. rainbow cycles the hue circle instead. The surface decides how the ink is laid: on a light one it covers as ink, its colour at full saturation under a coverage that rises with its density, so it reads as ink on white and not as a glow; on a dark one the thin end is pulled down, so a trail goes out crisply instead of hanging as haze.

<wr-fluid-cursor rainbow />

rainbow

The hue circle.

--wr-fluid-cursor-color-1: var(--wr-color-warning)

A warm palette.

<wr-fluid-cursor style="--wr-fluid-cursor-color-1: var(--wr-color-warning); --wr-fluid-cursor-color-2: var(--wr-color-danger)" />

Calmer

swirl is how strongly the eddies are fed back, force how much of the pointer's speed the fluid takes, size the radius of what a move lays, in px, intensity how much dye it lays, trail how long the dye lasts, in seconds, and momentum how long the motion does. Less swirl and force with a wider, longer trail is slow ink in still water.

<wr-fluid-cursor swirl="0.3" force="0.5" size="40" intensity="0.6" trail="5" />

Default

Lively.

swirl="0.3" force="0.5" size="40" intensity="0.6" trail="5"

Calm.

Click splash

A primary click or a tap on the parent splashes the ink outward from the point, a ring of eight rays in the next colour of the cycle; clickSplash false turns it off. The listener is passive and stops nothing, so a link or a button over the ink works as before.

<wr-fluid-cursor [clickSplash]="false" />

Default

Click here.

[clickSplash]="false"

A click does nothing here.

Quality

quality sizes the grids and the pressure solve: the velocity grid is 96, 128 or 192 texels on its short side, the dye grid 384, 640 or 1024 and no finer than the canvas, and the solve 12, 20 or 28 iterations. Both grids keep the region's aspect ratio and sit under the same pixel budget as the other canvas backdrops, so a wide footer is never a wide texture; shading false drops the relief lighting, the cheapest pass to lose.

<wr-fluid-cursor quality="high" />

API

NameDescriptionTypeDefault
intensityHow much dye a move lays, a multiplier.number1
sizeThe radius of a splat, in CSS px: how wide the ink the pointer lays is.number24
forceHow much of the pointer's speed the fluid takes, a multiplier.number1
swirlHow strongly the eddies are fed back, a multiplier: more curls the trail tighter, none lets it smooth out.number1
trailHow long the dye lasts, in seconds, from a fresh splat to gone.number3
momentumHow long the motion the pointer gave lasts, in seconds.number6
shadingWhether the dye is shaded by its own relief, a slight lighting from the top left.booleantrue
rainbowWhether a stroke cycles the hue circle instead of the theme's palette.booleanfalse
clickSplashWhether a click or a tap splashes the ink outward from the point.booleantrue
qualityHow finely the fluid is simulated: the grids and the pressure solve.WrFluidCursorQuality'medium'

Accessibility

PartBehaviour
Screen readers Decorative: the host is aria-hidden, holds no text, takes no pointer and is never focusable. The listeners on the parent are passive and stop nothing, so every control over the ink behaves as before.
Touch A finger draws like a mouse, and is never stopped from scrolling: nothing is prevented and touch-action is left alone, so a scroll that starts over the region scrolls.
Reduced motion Under prefers-reduced-motion: reduce nothing is drawn and no context is made; a trail caught moving when it is turned on is cleared. Under forced-colors: active nothing is drawn either.
No WebGL Without WebGL2, or WebGL1 with half-float render targets, nothing is drawn; a lost context is let go and rebuilt when the browser restores it.
ServerThe empty canvas is rendered, absolutely positioned, so the browser's first draw shifts no layout.

CSS variables

Set these on the element itself, with a rule more specific than its Declared on selector. In a global stylesheet, put a class of yours on an ancestor it really has: <body> for an overlay panel, which renders outside your markup. In a component stylesheet the selector alone works, but only for an element in that component's own template. A value on a wrapper or :root is shadowed, because ngwr/animations/fluid-cursor declares each default on its own element.

VariableDefaultDeclared on
--wr-fluid-cursor-color-1var(--wr-color-primary).wr-fluid-cursor
--wr-fluid-cursor-color-2var(--wr-color-danger).wr-fluid-cursor
--wr-fluid-cursor-color-3var(--wr-color-warning).wr-fluid-cursor
--wr-fluid-cursor-color-4var(--wr-color-success).wr-fluid-cursor
--wr-fluid-cursor-color-5var(--wr-color-info).wr-fluid-cursor

See also