Physics of Fluid Cursors
A technical look at implementing macOS-style cursors on the web, including high-DPI cursor assets, frame-independent smoothing, velocity-based tilt, text selection behaviour, and accessibility.
I have been working on a custom cursor system for my website, largely because the cursor occupies an unusual position in an interface. It is technically outside the page, yet it is also the part of the interface that follows a person continuously. Small changes to its movement therefore remain perceptible even when nobody is consciously paying attention to them.
I was particularly interested in the cursor movement used in macOS product demonstrations and applications such as Screen Studio. The pointer does not simply reproduce the mouse coordinates on every frame. Its movement is slightly softened, horizontal motion influences its rotation, and pressing produces a small change in scale. Text is treated differently again, since an I-beam needs to remain upright and stable against the typography around it.
I wanted to reproduce those characteristics on my website without allowing the cursor to become an attraction of its own. It still needed to behave like a cursor first. The additional motion should make interaction feel more considered without making ordinary pointing, clicking, or selecting text less precise.
Doing that involved considerably more than replacing the CSS cursor image. The implementation eventually included extracting usable frames from macOS .cur files, handling browser size restrictions, writing frame-rate-independent movement, calculating rotation from velocity, preserving text-selection state, and providing sensible fallbacks for devices where the effect should not run.
Sourcing the Cursor Geometry
Before working on movement, I needed cursor artwork that already had the proportions I wanted. Recreating the macOS pointer approximately would have introduced small differences in its angle, outline, hotspot, and dimensions, all of which become fairly noticeable when the cursor is visible throughout an entire interface.
I used the Sierra and newer macOS cursor set from antiden/macOS-cursors-for-Windows. The repository contains cursor assets covering macOS Sierra through Sonoma, including the standard arrow, link pointer, text I-beam, grab cursors, unavailable state, and resize indicators.
View antiden/macOS-cursors-for-Windows on GitHubThe original files were useful as a source, although using the .cur files directly exposed a browser limitation that was not immediately apparent.
The 128-Pixel Browser Limit and High-DPI Cursors
My first implementation used the cursor file directly through CSS with cursor: url('/cursors/Normal.cur'), auto;. The declaration was valid, but the browser ignored the custom cursor and displayed the normal system pointer instead. There was no corresponding console error, which made the failure look more like an incorrect path or unsupported file than a dimension problem.
I inspected the directory entries inside the .cur file to see which image frames it contained:
The file contained several resolutions:
Desktop browsers place limits on the dimensions of custom CSS cursors. Chromium, WebKit, and Gecko restrict unusually large cursor images, with 128 by 128 pixels generally acting as the upper boundary and considerably smaller assets being preferable for ordinary pointers.
That mattered because the first entry in Normal.cur was the 256 by 256 master image. Chromium encountered that oversized frame and rejected the resource rather than simply choosing one of the smaller frames contained later in the file.
Extracting 32px and 64px Assets
Instead of depending on the browser to interpret the multi-resolution cursor file, I extracted the resolutions that the website actually needed. Each cursor received a 32 by 32 standard image and a 64 by 64 version for high-density displays, together with rebuilt 32 by 32 .cur fallbacks.
The resulting assets were:
normal.png(32x32) andnormal@2x.png(64x64)link.png(32x32) andlink@2x.png(64x64)text.png(32x32) andtext@2x.png(64x64)pan.png(32x32) andpan@2x.png(64x64)closehand.png(32x32) andclosehand@2x.png(64x64)unavailable.png(32x32) andunavailable@2x.png(64x64)
The fallback stylesheet uses -webkit-image-set to provide the appropriate bitmap density where supported. A Retina display can therefore use the 2x asset while the standard image remains available at 1x.
At this point the static cursor assets behaved reliably. I could then treat movement as a separate concern rather than trying to solve rendering and animation at the same time.
Building the Follower with Delta-Time Smoothing
A CSS cursor follows the operating system's pointer position directly. That is appropriate for normal interaction, but it does not provide the slight inertia I was trying to reproduce. For that, I added a visual cursor layer whose position is updated with requestAnimationFrame.
The important distinction is that the hardware pointer remains the source of truth. Pointer events establish the target coordinates, while the rendered follower approaches those coordinates over time. This allows the visual cursor to retain a small amount of movement after the physical mouse has changed direction or stopped.
Keeping the Motion Independent of Refresh Rate
A straightforward way to smooth a follower is to move it by a fixed fraction of the remaining distance on every rendered frame:
The problem is that a frame is not a stable unit of time. A 60Hz display evaluates that movement approximately 60 times each second, while a 120Hz display may evaluate it twice as often. The same interpolation constant can therefore produce noticeably different movement depending on the refresh rate of the display.
I instead calculate the elapsed time between frames and derive the interpolation amount using exponential decay:
Here, 24 controls the smoothing frequency in inverse seconds, while dt represents the time elapsed since the previous rendered frame. I also clamp dt to 50 milliseconds so returning to a tab after an interruption does not cause the cursor to make an unusually large correction in a single frame.
This makes the timing of the movement substantially more consistent across displays. The cursor still trails the hardware position slightly, but that distance remains small enough that interaction does not feel detached from the mouse itself.
Deriving Tilt from Horizontal Velocity
Position smoothing provided the basic movement, but the pointer still looked somewhat rigid while changing direction. I therefore allowed the arrow cursor to rotate slightly according to its horizontal velocity.
The calculation is deliberately restrained:
Horizontal velocity is multiplied by a gain of 2.8, after which the result is constrained between -6.5 and +6.5 degrees. Rotation then approaches that target using its own exponential smoothing value rather than immediately matching it.
The limits matter more than the effect itself. A pointer can tolerate a small amount of rotation because its silhouette already implies direction, but too much movement begins to alter the apparent position of its tip. Keeping the range narrow preserves the usefulness of the pointer while giving directional movement a little more visual continuity.
Press Feedback Through Scale
Clicks use the same principle of adding movement without changing the meaning of the cursor. While the pointer is pressed, its scale is reduced slightly and then restored after release.
The scale values are intentionally close to one another:
- Resting state: 1.0
- Interactive link state: 1.04
- Active press: 0.90
The change is enough to make a press visible without making the cursor appear to jump between unrelated sizes. Because the transition is interpolated rather than switched immediately, releasing the mouse restores the pointer with the same movement characteristics as the rest of the system.
Treating the Text I-Beam Separately
Text exposed a different set of problems. The arrow cursor could tolerate smoothing and rotation, but an I-beam sits directly against lines, glyphs, and selection boundaries. Even a small angle becomes apparent when the cursor is surrounded by otherwise upright typography.
There was also a detection problem. My initial cursor logic did not recognise every piece of article typography as selectable text, so the arrow could remain visible over paragraphs where the expected cursor was an I-beam.
I ended up handling text through three related decisions.
1. Detecting Text Across the Article
Text detection is not limited to form fields. The cursor checks the target and its ancestors for the semantic elements that make up article content:
Interactive elements are checked before this rule. That ordering is important because a link may sit inside a paragraph while still requiring the pointer cursor rather than the surrounding text cursor. Buttons and elements using role="button" receive the same precedence.
The cursor type is therefore determined by what the element does, not merely by the type of content surrounding it.
2. Locking the I-Beam to Zero Degrees
The velocity-based rotation used for the arrow does not apply to text. Whenever the detected cursor type is text, its target rotation is explicitly set to zero:
This keeps the I-beam vertical regardless of how quickly the mouse is moving horizontally. It is a small exception in the animation system, but one that makes the cursor considerably easier to read against text.
3. Preserving the I-Beam During Selection
Selecting text introduces another edge case because the pointer does not necessarily remain above the text being selected. A person may begin dragging inside a paragraph and continue into the margin or another piece of empty space.
If cursor detection were performed independently at every position, leaving the paragraph would immediately change the I-beam back into an arrow even though a text selection was still in progress. The cursor would then alternate between states according to whatever happened to sit beneath it.
I keep a small piece of selection state for the duration of the pointer interaction:
When a pointer-down event begins on text, isSelectingText remains active until the corresponding release. The visual cursor can therefore remain an I-beam while the pointer temporarily passes outside the text element during a drag.
Interactive Cursor Lab
I also made a small sandbox for testing the cursor independently of the rest of the article. It exposes the individual cursor presets, hotspot behaviour, smoothing, and tilt so the values can be examined without repeatedly changing the implementation itself.
Hover across this paragraph to test the macOS text I-beam. The follower locks its orientation to 0 degrees to avoid tilt oscillations along typographic baselines. Drag to select text to test selection locking.
Adding Click Ripples
The final movement detail was a small ripple at the location of a pointer press. Its purpose is simply to make the exact click position visible for a moment, particularly when the cursor itself is moving or changing scale.
The ripple is implemented as a lightweight DOM element created at the pointer coordinates on pointerdown. CSS handles its expansion and disappearance:
Both the ripple and the cursor follower use pointer-events: none. They remain visual layers only, so neither can intercept clicks, interfere with text selection, or alter native drag-and-drop behaviour.
Accessibility and Device Boundaries
A custom cursor is useful only on devices where a cursor actually makes sense. There is little reason to initialise an animated pointer layer on a touchscreen, and motion preferences should take precedence over the visual effect.
The implementation therefore checks window.matchMedia("(pointer: fine)") before mounting the follower. Phones, tablets, and other devices without a fine pointing device retain their normal touch behaviour without carrying cursor-specific logic that they cannot use.
It also checks window.matchMedia("(prefers-reduced-motion: reduce)"). When reduced motion is enabled at the operating-system level, the animated follower is disabled and the website falls back to the static high-resolution cursor assets.
These are not secondary additions to the effect. They define where the effect is appropriate in the first place. A cursor system designed for precise mouse input should not assume that every visitor has that form of input or wants the additional movement.
What the Cursor Actually Changes
None of this changes what the website allows someone to do. Articles remain readable with an ordinary system cursor, links behave the same way, text can still be selected, and the underlying interface does not depend on the follower.
The difference is mostly in how those interactions are expressed. Position smoothing makes movement less abrupt, velocity gives the arrow a restrained directional response, press scaling acknowledges clicks, and the text-specific rules prevent those same effects from interfering with typography.
That distinction became the useful part of working on this. Small interface details do not necessarily need to add functionality to justify their existence. Sometimes their purpose is to make existing behaviour more coherent, provided they remain subordinate to the interaction they are meant to support.
The cursor is particularly suited to that kind of work because it accompanies almost every desktop interaction without needing additional space in the interface. Once its assets, motion, state detection, and accessibility boundaries are treated as parts of the same system, it can carry a little more character without asking the reader to think about the cursor at all.