Cursor Text
A fixed 12 px label offset 15 px below the pointer, rewriting the mouse's local coordinates on every move — “X: 228 Y: 180”, rounded to the pixel, measured from the zone's top-left corner. The native cursor is hidden: the label is the only landmark.
You'll get access to the interactive demo with a free account.
This effect is part of Effect.Labs — 811 vanilla effects, some free, some premium. Cursors has 26 effects, including 1 free. Explore the category →
3 usage examples



How it works
A single element carries the effect: #cursorText (.custom-cursor.cursor-text-display). The .custom-cursor class takes it out of the flow — position: fixed, pointer-events: none, z-index: 9999, display: none — and .cursor-text-display dresses it: font: 600 .75rem Inter, monospace, color #6366f1, background rgba(99,102,241,.1), 1 px border rgba(99,102,241,.3), padding: 4px 8px, border-radius: 4px, white-space: nowrap and, above all, transform: translate(15px, 15px), the offset that keeps it away from the hot spot.
The #zone-text area gets three listeners. mouseenter adds the cursor-active class to the zone — this time the rule does exist in the shipped CSS: .cursor-active { cursor: none !important }, the system arrow disappears (measured: cursor: none) — and switches the label to display: block. mouseleave undoes both. mousemove does the work: localX = Math.round(clientX − rect.left), same for Y, then textContent = `X: ${localX} Y: ${localY}` and left = clientX + 'px', top = clientY + 'px'.
The written coordinates are local to the zone (0, 0 = its top-left corner), recomputed through getBoundingClientRect() on every event — so they stay right even if the page has scrolled or the zone has moved. Positioning, on the other hand, uses viewport coordinates (clientX/Y, consistent with position: fixed). Measured: for a pointer at (228, 180) inside the zone, the text reads exactly “X: 228 Y: 180” and the label's top-left corner sits at +15 px, +15 px from the pointer; the label measures 112 × 24 px.
No requestAnimationFrame, no smoothing: the label is glued to the pointer, 0 rAF calls/s outside hover; the per-event cost is one textContent write (relayout of the label alone). The shipped JS file contains only this block (“Zone 9: Cursor Text”), nothing from another effect. The .cursor-zone rule defines its five variables locally: the demo zone renders correctly on a blank page.
Accessibility
- Insufficient label contrast:
#6366f1on its background (rgba(99,102,241,.1)composited over#1a1a24, i.e.#212238) = 3.5:1 for 12 px text, below the 4.5:1 AA threshold. Switch the color to#a5b4fc(7.8:1) or#c7d2fe(10.4:1);#818cf8(5.2:1) stays closest to the original. cursor: nonemakes the label the only pointer indicator, yet it is offset 15 px down-right: nothing marks the exact hot spot. For a precision interface, remove the.cursor-activerule (the arrow returns, the label rides along) or add a 4 px::beforeattranslate(-15px, -15px)that sits on the pointer.- Screen readers: the
divchanges text on every move. It has noaria-live, so nothing is announced, but it remains in the accessibility tree: addaria-hidden="true"so assistive technology ignores it entirely. If the coordinates carry meaning (a position picker), also expose them in a field or anoutputelement. - Touch: measured with touch emulation, a tap inside the zone shows the label frozen on the tap coordinates and leaves
cursor-activein place even after a tap outside the zone (nomouseleave). Gate the initialization onmatchMedia('(hover: hover) and (pointer: fine)').matches. - Keyboard: no equivalent, the label never shows — acceptable as long as it is only a convenience. No autonomous motion:
prefers-reduced-motionhas nothing to disable.z-index: 9999: the label floats above open modals and menus.
Browser compatibility
Requires ES2015 (template literals, arrow functions, const), classList, getBoundingClientRect and the zone's CSS variables. Zero dependencies.
Without JavaScript, the label stays display: none and the system arrow is kept: the zone is an ordinary box. Without CSS variables, the zone loses its background and radius; the label, which uses none, stays intact.
The code
HTML structure to paste into your page (CSS + JS available with a premium account):
<div class="cursor-zone" id="zone-text">
Hover this area
<span class="info-text">Following label</span>
</div>
<!-- This div IS the cursor: the script moves it and shows it.
Without it the script bails out and the effect renders nothing. -->
<div class="custom-cursor cursor-text-display" id="cursorText">X: 0 Y: 0</div>
Full HTML + CSS + JS, copy-paste ready — with hundreds of premium effects.
Customize
Options passed to the API or data-* attributes:
| Option / property | Default | Effect |
|---|---|---|
| Offset (CSS — .cursor-text-display transform) | translate(15px, 15px) | Label position relative to the hot spot. Negative values place it above or to the left; 0 centers it on the pointer and hides what it targets. |
Content (JS — cursorText.textContent = …) |
X: ${localX} Y: ${localY} | What the label reads. A fixed text (“View”, “Drag”) is set once; for units, divide by rect.width for percentages. |
| Text color (CSS — .cursor-text-display color) | #6366f1 | 3.5:1 on the shipped background. #a5b4fc or #c7d2fe pass the 4.5:1 threshold without changing the hue. |
| Font and size (CSS — .cursor-text-display font) | 600 .75rem Inter, monospace | Inter is not shipped: the stack falls back to monospace. A deliberate monospace (ui-monospace, Menlo) avoids width jumps as digits change. |
| Background and border (CSS — .cursor-text-display background, border) | rgba(99,102,241,.1) / 1px rgba(99,102,241,.3) | Chip styling. An opaque background (#0f0f1a) guarantees legibility over an image; without a border, the label becomes plain floating text. |
| Native cursor hiding (CSS — .cursor-active) | cursor: none !important | Delete the rule to keep the system arrow and only add the label — recommended as soon as the zone holds precision targets. |
Coordinate rounding (JS — Math.round) |
to the pixel | Math.round(x * 10) / 10 for a tenth of a pixel (useful when zoomed), or Math.floor to stay in the hovered cell of a grid. |
FAQ
cursorText.textContent = `X: ${localX} Y: ${localY}` with the text you want, or set it once in the HTML and delete that line along with the two now-useless Math.round. For a text that depends on the hovered element, read e.target.closest('[data-cursor-text]') and copy its attribute — the rest of the code (display, position, offset) is unchanged.#cursorText has transform, filter, perspective, will-change: transform or contain: paint: it becomes the containing block of the position: fixed element, and left = clientX is then measured from that ancestor, no longer from the window. The shipped code places the label inside the demo zone; when integrating, move it right before </body>, outside any transformed container.rect = zoneText.getBoundingClientRect(), then clientX − rect.left — (0, 0) is the zone's top-left corner, regardless of scrolling. For percentages: Math.round((clientX − rect.left) / rect.width × 100). For document coordinates: e.pageX and e.pageY directly. The label's positioning must stay in clientX/Y, since it is fixed.